第一次提交
This commit is contained in:
35
app-instance/backend/nanobot/agent/__init__.py
Normal file
35
app-instance/backend/nanobot/agent/__init__.py
Normal 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)
|
||||
394
app-instance/backend/nanobot/agent/agent_registry.py
Normal file
394
app-instance/backend/nanobot/agent/agent_registry.py
Normal file
@ -0,0 +1,394 @@
|
||||
"""统一 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_-]+")
|
||||
|
||||
|
||||
@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_group: bool = True
|
||||
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,
|
||||
):
|
||||
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.workspace_store = WorkspaceAgentStore(workspace)
|
||||
|
||||
def list_agents(self, include_local_fallback: bool = True) -> list[AgentDescriptor]:
|
||||
"""按统一格式列出当前可见 agent。"""
|
||||
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 本质上是“带独立系统提示词的本地执行器”。
|
||||
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"],
|
||||
support_group=True,
|
||||
)
|
||||
)
|
||||
|
||||
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。"""
|
||||
tokens = {token for token in _TOKEN_RE.findall((query or "").lower()) if len(token) > 2}
|
||||
if not tokens:
|
||||
return []
|
||||
|
||||
scored: list[tuple[int, AgentDescriptor]] = []
|
||||
for agent in self.list_agents(include_local_fallback=False):
|
||||
haystack = agent.searchable_text()
|
||||
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
|
||||
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]]
|
||||
|
||||
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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
)
|
||||
|
||||
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(
|
||||
f" <supports-group>{str(agent.support_group).lower()}</supports-group>"
|
||||
)
|
||||
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_group=bool(record.get("support_group", True)),
|
||||
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_group=bool(card.get("support_group", True)),
|
||||
support_streaming=bool(card.get("support_streaming", False)),
|
||||
)
|
||||
252
app-instance/backend/nanobot/agent/context.py
Normal file
252
app-instance/backend/nanobot/agent/context.py
Normal file
@ -0,0 +1,252 @@
|
||||
"""上下文构建器:负责为每次 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:
|
||||
# 把可委派 agent 目录加入 system prompt,模型才知道 `spawn` 能调用谁。
|
||||
agents_summary = self.agent_registry.build_agents_summary()
|
||||
if agents_summary:
|
||||
parts.append(f"""# Available Agents
|
||||
|
||||
The following agents can be delegated to via the `spawn` tool.
|
||||
Use `target` for a single agent and `targets` for a group.
|
||||
|
||||
{agents_summary}""")
|
||||
|
||||
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"""# nanobot 🐈
|
||||
|
||||
You are nanobot, 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.
|
||||
|
||||
## 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
|
||||
837
app-instance/backend/nanobot/agent/delegation.py
Normal file
837
app-instance/backend/nanobot/agent/delegation.py
Normal file
@ -0,0 +1,837 @@
|
||||
"""统一委派管理器。
|
||||
|
||||
这是本次多 agent 改造的核心编排层,负责:
|
||||
1. 根据目标 / 策略选择本地 agent、plugin agent、A2A 远端 agent 或 group;
|
||||
2. 跟踪每次后台委派的运行状态,支持取消;
|
||||
3. 统一发出 bus 公告和结构化 process events;
|
||||
4. 在本地执行器和 A2A 客户端之间做协议桥接。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.a2a.client import A2AClient, A2AStreamEvent
|
||||
from nanobot.agent.agent_registry import AgentDescriptor, AgentRegistry
|
||||
from nanobot.agent.process_events import (
|
||||
emit_process_event,
|
||||
has_process_event_sink,
|
||||
new_run_id,
|
||||
process_run_context,
|
||||
)
|
||||
from nanobot.agent.run_result import AgentRunResult
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMProvider
|
||||
|
||||
|
||||
@dataclass
|
||||
class DelegationRun:
|
||||
"""记录一次正在运行的委派任务及其远端子任务状态。"""
|
||||
|
||||
# 后台 asyncio 任务句柄,用于取消和生命周期管理。
|
||||
task: asyncio.Task[None]
|
||||
# 面向日志/UI 的短标签。
|
||||
label: str
|
||||
# 原会话路由,委派完成后需要把结果送回这里。
|
||||
origin: dict[str, str]
|
||||
# 是否通过 bus 回注 system 消息;直连模式下通常为 False。
|
||||
announce_via_bus: bool = True
|
||||
# 远端 agent 描述和 task_id 映射,用于取消 A2A 子任务。
|
||||
remote_agents: dict[str, AgentDescriptor] = field(default_factory=dict)
|
||||
remote_task_ids: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
class DelegationManager:
|
||||
"""把任务分发到本地、插件、远端 A2A 或 agent group。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider: LLMProvider,
|
||||
workspace: Path,
|
||||
bus: MessageBus,
|
||||
registry: AgentRegistry,
|
||||
local_executor: Any,
|
||||
timeout_seconds: int = 30,
|
||||
poll_interval_seconds: int = 2,
|
||||
card_cache_ttl_seconds: int = 300,
|
||||
max_parallel_agents: int = 4,
|
||||
allowed_hosts: list[str] | None = None,
|
||||
authz_config: Any | None = None,
|
||||
backend_identity: Any | None = None,
|
||||
):
|
||||
self.provider = provider
|
||||
self.workspace = workspace
|
||||
self.bus = bus
|
||||
self.registry = registry
|
||||
# local_executor 只负责“本地执行”,不再承担队列编排职责。
|
||||
self.local_executor = local_executor
|
||||
self.max_parallel_agents = max(1, max_parallel_agents)
|
||||
# A2AClient 只处理远端协议细节,委派策略和公告统一放在本类。
|
||||
self.a2a_client = A2AClient(
|
||||
timeout_seconds=timeout_seconds,
|
||||
poll_interval_seconds=poll_interval_seconds,
|
||||
card_cache_ttl_seconds=card_cache_ttl_seconds,
|
||||
allowed_hosts=allowed_hosts,
|
||||
authz_config=authz_config,
|
||||
backend_identity=backend_identity,
|
||||
)
|
||||
self._running_tasks: dict[str, DelegationRun] = {}
|
||||
|
||||
async def dispatch(
|
||||
self,
|
||||
task: str,
|
||||
label: str | None = None,
|
||||
target: str | None = None,
|
||||
targets: list[str] | None = None,
|
||||
strategy: str = "auto",
|
||||
origin_channel: str = "cli",
|
||||
origin_chat_id: str = "direct",
|
||||
announce_via_bus: bool = True,
|
||||
) -> str:
|
||||
"""启动一个后台委派任务,并立即返回已启动提示。"""
|
||||
run_id = str(uuid.uuid4())[:8]
|
||||
display_label = label or task[:30] + ("..." if len(task) > 30 else "")
|
||||
origin = {"channel": origin_channel, "chat_id": origin_chat_id}
|
||||
# 真正执行逻辑放后台任务里,避免阻塞当前对话回合。
|
||||
bg_task = asyncio.create_task(
|
||||
self._run_dispatch(
|
||||
run_id=run_id,
|
||||
task=task,
|
||||
label=display_label,
|
||||
target=target,
|
||||
targets=targets or [],
|
||||
strategy=(strategy or "auto").lower(),
|
||||
origin=origin,
|
||||
)
|
||||
)
|
||||
self._running_tasks[run_id] = DelegationRun(
|
||||
task=bg_task,
|
||||
label=display_label,
|
||||
origin=origin,
|
||||
announce_via_bus=announce_via_bus,
|
||||
)
|
||||
bg_task.add_done_callback(lambda _: self._running_tasks.pop(run_id, None))
|
||||
logger.info("Delegation [{}] started: {}", run_id, display_label)
|
||||
return (
|
||||
f"Delegation [{display_label}] started (id: {run_id}). "
|
||||
"I'll notify you when it completes."
|
||||
)
|
||||
|
||||
def get_running_count(self) -> int:
|
||||
"""返回当前正在执行的委派数量。"""
|
||||
return len(self._running_tasks)
|
||||
|
||||
@staticmethod
|
||||
def _ui_status(status: str | None) -> str:
|
||||
"""把底层状态归一化成前端更稳定的显示状态。"""
|
||||
probe = (status or "").strip().lower()
|
||||
if probe in {"", "ok", "done", "completed", "complete", "success"}:
|
||||
return "done"
|
||||
if probe in {"working", "running", "queued", "submitted", "waiting", "in_progress"}:
|
||||
return "running" if probe != "waiting" else "waiting"
|
||||
if probe in {"cancelled", "canceled"}:
|
||||
return "cancelled"
|
||||
if probe in {"failed", "error"}:
|
||||
return "error"
|
||||
return probe or "running"
|
||||
|
||||
async def _emit_agent_started(
|
||||
self,
|
||||
run_id: str,
|
||||
descriptor: AgentDescriptor,
|
||||
label: str,
|
||||
*,
|
||||
parent_run_id: str | None = None,
|
||||
) -> None:
|
||||
# 单 agent 执行开始事件,供前端画执行树。
|
||||
await emit_process_event(
|
||||
"process_run_started",
|
||||
run_id=run_id,
|
||||
parent_run_id=parent_run_id,
|
||||
actor_type="agent",
|
||||
actor_id=descriptor.id,
|
||||
actor_name=descriptor.name,
|
||||
source=descriptor.source,
|
||||
title=label,
|
||||
status="running",
|
||||
metadata={
|
||||
"kind": descriptor.kind,
|
||||
"protocol": descriptor.protocol,
|
||||
"support_group": descriptor.support_group,
|
||||
"support_streaming": descriptor.support_streaming,
|
||||
},
|
||||
)
|
||||
|
||||
async def _emit_agent_finished(
|
||||
self,
|
||||
run_id: str,
|
||||
descriptor: AgentDescriptor,
|
||||
result: AgentRunResult,
|
||||
) -> None:
|
||||
# 单 agent 结束事件只保留归一化状态和摘要,原始状态放 metadata 里。
|
||||
await emit_process_event(
|
||||
"process_run_finished",
|
||||
run_id=run_id,
|
||||
actor_type="agent",
|
||||
actor_id=descriptor.id,
|
||||
actor_name=descriptor.name,
|
||||
status=self._ui_status(result.status),
|
||||
summary=result.summary,
|
||||
metadata={"raw_status": result.status},
|
||||
)
|
||||
|
||||
async def _emit_agent_cancelled(
|
||||
self,
|
||||
run_id: str,
|
||||
descriptor: AgentDescriptor | None,
|
||||
label: str,
|
||||
) -> None:
|
||||
# 取消事件允许 descriptor 为空,用于还没解析出具体目标就被取消的情况。
|
||||
await emit_process_event(
|
||||
"process_run_cancelled",
|
||||
run_id=run_id,
|
||||
actor_type="agent" if descriptor is not None else "system",
|
||||
actor_id=descriptor.id if descriptor is not None else "delegation",
|
||||
actor_name=descriptor.name if descriptor is not None else label,
|
||||
status="cancelled",
|
||||
)
|
||||
|
||||
async def _emit_group_started(self, run_id: str, label: str, targets: list[str]) -> None:
|
||||
"""发送 group delegation 开始事件。"""
|
||||
await emit_process_event(
|
||||
"process_run_started",
|
||||
run_id=run_id,
|
||||
parent_run_id=None,
|
||||
actor_type="system",
|
||||
actor_id="agent-group",
|
||||
actor_name="Agent Group",
|
||||
title=label,
|
||||
status="running",
|
||||
metadata={"targets": targets},
|
||||
)
|
||||
|
||||
async def _emit_group_finished(self, run_id: str, label: str, results: list[AgentRunResult]) -> None:
|
||||
"""发送 group delegation 结束事件。"""
|
||||
await emit_process_event(
|
||||
"process_run_finished",
|
||||
run_id=run_id,
|
||||
actor_type="system",
|
||||
actor_id="agent-group",
|
||||
actor_name="Agent Group",
|
||||
status="done",
|
||||
summary=f"{label}: {len(results)} member(s) finished",
|
||||
metadata={
|
||||
"members": [
|
||||
{
|
||||
"agent_id": item.agent_id,
|
||||
"agent_name": item.agent_name,
|
||||
"status": item.status,
|
||||
}
|
||||
for item in results
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
async def _publish_prefixed_progress(
|
||||
self,
|
||||
origin: dict[str, str],
|
||||
descriptor: AgentDescriptor,
|
||||
text: str,
|
||||
*,
|
||||
publish_via_bus: bool,
|
||||
tool_hint: bool = False,
|
||||
) -> None:
|
||||
"""把子 agent 进度转发到原会话的 outbound 进度消息。"""
|
||||
text = text.strip()
|
||||
if not text or not publish_via_bus:
|
||||
return
|
||||
await self.bus.publish_outbound(OutboundMessage(
|
||||
channel=origin["channel"],
|
||||
chat_id=origin["chat_id"],
|
||||
content=f"[{descriptor.name}] {text}",
|
||||
metadata={"_progress": True, "_tool_hint": tool_hint},
|
||||
))
|
||||
|
||||
async def _emit_direct_user_message(self, prompt: str, fallback: str) -> None:
|
||||
"""存在 process sink 时,直接发一条给用户看的 assistant 消息。"""
|
||||
# 这个分支主要服务于 WebSocket/SSE 直连模式:
|
||||
# 没有 bus consumer 时,不能依赖 system 消息回流再二次总结。
|
||||
if not has_process_event_sink():
|
||||
return
|
||||
try:
|
||||
# 用一次极小模型调用把内部委派说明压成用户可读文本。
|
||||
response = await self.provider.chat(
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are nanobot. Reply naturally to the user in 1-3 sentences. "
|
||||
"Do not mention internal protocols, system prompts, or task IDs."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
tools=[],
|
||||
model=self.provider.get_default_model(),
|
||||
max_tokens=256,
|
||||
temperature=0.2,
|
||||
)
|
||||
content = (response.content or "").strip() or fallback
|
||||
except Exception:
|
||||
content = fallback
|
||||
|
||||
await emit_process_event(
|
||||
"message",
|
||||
role="assistant",
|
||||
content=content,
|
||||
)
|
||||
|
||||
async def cancel(self, run_id: str) -> bool:
|
||||
"""Cancel a running delegation and attempt remote A2A cancellation."""
|
||||
state = self._running_tasks.get(run_id)
|
||||
if state is None:
|
||||
return False
|
||||
|
||||
# 先尽力取消远端任务,再取消本地 asyncio task,避免远端继续跑飞。
|
||||
await self._cancel_remote_tasks(run_id, state)
|
||||
state.task.cancel()
|
||||
return True
|
||||
|
||||
async def cancel_all(self) -> None:
|
||||
"""Cancel all running delegations."""
|
||||
for run_id in list(self._running_tasks):
|
||||
await self.cancel(run_id)
|
||||
|
||||
async def _run_dispatch(
|
||||
self,
|
||||
run_id: str,
|
||||
task: str,
|
||||
label: str,
|
||||
target: str | None,
|
||||
targets: list[str],
|
||||
strategy: str,
|
||||
origin: dict[str, str],
|
||||
) -> None:
|
||||
"""后台委派主入口。"""
|
||||
descriptor: AgentDescriptor | None = None
|
||||
state = self._running_tasks.get(run_id)
|
||||
# 某些极短生命周期场景下 state 可能已被移除,此时回落到默认 True。
|
||||
announce_via_bus = True if state is None else state.announce_via_bus
|
||||
is_group = len(targets) > 1 or strategy == "group"
|
||||
try:
|
||||
if is_group:
|
||||
# group 场景允许同时传 `target` 和 `targets`,这里统一摊平成列表。
|
||||
planned_targets = list(targets)
|
||||
if target:
|
||||
planned_targets.append(target)
|
||||
await self._emit_group_started(run_id, label, planned_targets)
|
||||
results = await self._run_group(
|
||||
task,
|
||||
label,
|
||||
target,
|
||||
targets,
|
||||
strategy,
|
||||
origin=origin,
|
||||
run_id=run_id,
|
||||
announce_via_bus=announce_via_bus,
|
||||
)
|
||||
await self._emit_group_finished(run_id, label, results)
|
||||
await self._announce_group_result(
|
||||
run_id,
|
||||
label,
|
||||
task,
|
||||
results,
|
||||
origin,
|
||||
announce_via_bus=announce_via_bus,
|
||||
)
|
||||
return
|
||||
|
||||
# 单 agent 场景先解析目标,再执行。
|
||||
descriptor = self._resolve_single(task, target, strategy)
|
||||
await self._emit_agent_started(run_id, descriptor, label)
|
||||
progress_callback = self._build_progress_callback(
|
||||
origin,
|
||||
descriptor,
|
||||
event_run_id=run_id,
|
||||
tracking_run_id=run_id,
|
||||
publish_via_bus=announce_via_bus,
|
||||
)
|
||||
result = await self._execute_descriptor(
|
||||
descriptor,
|
||||
task,
|
||||
label,
|
||||
event_callback=progress_callback,
|
||||
task_callback=self._build_task_callback(run_id, descriptor),
|
||||
process_run_id=run_id,
|
||||
)
|
||||
await self._emit_agent_finished(run_id, descriptor, result)
|
||||
await self._announce_single_result(
|
||||
run_id,
|
||||
label,
|
||||
task,
|
||||
result,
|
||||
origin,
|
||||
announce_via_bus=announce_via_bus,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Delegation [{}] cancelled", run_id)
|
||||
if is_group:
|
||||
await emit_process_event(
|
||||
"process_run_cancelled",
|
||||
run_id=run_id,
|
||||
actor_type="system",
|
||||
actor_id="agent-group",
|
||||
actor_name="Agent Group",
|
||||
status="cancelled",
|
||||
)
|
||||
else:
|
||||
await self._emit_agent_cancelled(run_id, descriptor, label)
|
||||
await self._announce_cancelled(
|
||||
run_id,
|
||||
label,
|
||||
task,
|
||||
origin,
|
||||
announce_via_bus=announce_via_bus,
|
||||
)
|
||||
raise
|
||||
except Exception as exc:
|
||||
# 所有异常统一转换成 AgentRunResult 风格的错误结果,避免上层出现未处理异常。
|
||||
logger.error("Delegation [{}] failed: {}", run_id, exc)
|
||||
error_result = AgentRunResult(
|
||||
agent_id=target or "delegation",
|
||||
agent_name=target or "delegation",
|
||||
status="error",
|
||||
summary=f"Error: {exc}",
|
||||
)
|
||||
if is_group:
|
||||
await emit_process_event(
|
||||
"process_run_finished",
|
||||
run_id=run_id,
|
||||
actor_type="system",
|
||||
actor_id="agent-group",
|
||||
actor_name="Agent Group",
|
||||
status="error",
|
||||
summary=f"Error: {exc}",
|
||||
)
|
||||
elif descriptor is not None:
|
||||
await self._emit_agent_finished(run_id, descriptor, error_result)
|
||||
await self._announce_single_result(
|
||||
run_id,
|
||||
label,
|
||||
task,
|
||||
error_result,
|
||||
origin,
|
||||
announce_via_bus=announce_via_bus,
|
||||
)
|
||||
|
||||
def _resolve_single(self, task: str, target: str | None, strategy: str) -> AgentDescriptor:
|
||||
"""按显式目标或路由策略解析单个 agent。"""
|
||||
if target:
|
||||
descriptor = self.registry.get_agent(target)
|
||||
if descriptor is None:
|
||||
raise ValueError(f"Agent '{target}' not found")
|
||||
return descriptor
|
||||
|
||||
if strategy == "local":
|
||||
descriptor = self.registry.get_agent("local-subagent")
|
||||
if descriptor is None:
|
||||
raise ValueError("Local subagent is not available")
|
||||
return descriptor
|
||||
|
||||
if strategy == "plugin":
|
||||
suggestions = [
|
||||
agent for agent in self.registry.suggest_agents(task)
|
||||
if agent.kind == "local_prompt" and agent.source == "plugin"
|
||||
]
|
||||
if suggestions:
|
||||
return suggestions[0]
|
||||
raise ValueError("No matching plugin agent found")
|
||||
|
||||
if strategy == "a2a":
|
||||
suggestions = [
|
||||
agent for agent in self.registry.suggest_agents(task)
|
||||
if agent.protocol == "a2a"
|
||||
]
|
||||
if suggestions:
|
||||
return suggestions[0]
|
||||
raise ValueError("No matching A2A agent found")
|
||||
|
||||
suggestions = self.registry.suggest_agents(task, limit=1)
|
||||
if suggestions:
|
||||
return suggestions[0]
|
||||
# 自动路由一个都猜不到时,最后回到本地兜底 agent。
|
||||
descriptor = self.registry.get_agent("local-subagent")
|
||||
if descriptor is None:
|
||||
raise ValueError("Local fallback agent is not available")
|
||||
return descriptor
|
||||
|
||||
async def _run_group(
|
||||
self,
|
||||
task: str,
|
||||
label: str,
|
||||
target: str | None,
|
||||
targets: list[str],
|
||||
strategy: str,
|
||||
origin: dict[str, str],
|
||||
run_id: str,
|
||||
announce_via_bus: bool,
|
||||
) -> list[AgentRunResult]:
|
||||
"""并行执行一组 agent,并汇总结果。"""
|
||||
resolved_targets = list(targets)
|
||||
if target:
|
||||
resolved_targets.append(target)
|
||||
if not resolved_targets:
|
||||
# 未显式给出目标时,根据任务文本自动挑若干个候选 agent。
|
||||
suggestions = self.registry.suggest_agents(task, limit=self.max_parallel_agents)
|
||||
resolved_targets = [agent.id for agent in suggestions]
|
||||
if not resolved_targets:
|
||||
raise ValueError("No agents available for group delegation")
|
||||
resolved_targets = list(dict.fromkeys(resolved_targets))
|
||||
|
||||
descriptors: list[AgentDescriptor] = []
|
||||
missing: list[str] = []
|
||||
for item in resolved_targets:
|
||||
descriptor = self.registry.get_agent(item)
|
||||
if descriptor is None:
|
||||
missing.append(item)
|
||||
else:
|
||||
descriptors.append(descriptor)
|
||||
if missing:
|
||||
raise ValueError(f"Agent(s) not found: {', '.join(missing)}")
|
||||
|
||||
semaphore = asyncio.Semaphore(self.max_parallel_agents)
|
||||
|
||||
async def _run_one(descriptor: AgentDescriptor) -> AgentRunResult:
|
||||
# group 内每个成员都分配独立 child run_id,便于前端区分子树。
|
||||
child_run_id = new_run_id("agent")
|
||||
async with semaphore:
|
||||
try:
|
||||
await self._emit_agent_started(child_run_id, descriptor, label, parent_run_id=run_id)
|
||||
result = await self._execute_descriptor(
|
||||
descriptor,
|
||||
task,
|
||||
label,
|
||||
event_callback=self._build_progress_callback(
|
||||
origin,
|
||||
descriptor,
|
||||
event_run_id=child_run_id,
|
||||
tracking_run_id=run_id,
|
||||
publish_via_bus=announce_via_bus,
|
||||
),
|
||||
task_callback=self._build_task_callback(run_id, descriptor),
|
||||
process_run_id=child_run_id,
|
||||
)
|
||||
await self._emit_agent_finished(child_run_id, descriptor, result)
|
||||
return result
|
||||
except asyncio.CancelledError:
|
||||
await self._emit_agent_cancelled(child_run_id, descriptor, label)
|
||||
raise
|
||||
except Exception as exc:
|
||||
result = AgentRunResult(
|
||||
agent_id=descriptor.id,
|
||||
agent_name=descriptor.name,
|
||||
status="error",
|
||||
summary=f"Error: {exc}",
|
||||
)
|
||||
await self._emit_agent_finished(child_run_id, descriptor, result)
|
||||
return result
|
||||
results = await asyncio.gather(*[_run_one(agent) for agent in descriptors])
|
||||
return results
|
||||
|
||||
async def _execute_descriptor(
|
||||
self,
|
||||
descriptor: AgentDescriptor,
|
||||
task: str,
|
||||
label: str,
|
||||
event_callback=None,
|
||||
task_callback=None,
|
||||
process_run_id: str | None = None,
|
||||
) -> AgentRunResult:
|
||||
"""根据 descriptor 类型执行具体 agent。"""
|
||||
logger.info("Delegating '{}' to {}", label, descriptor.id)
|
||||
if descriptor.kind in {"local_fallback", "local_prompt"}:
|
||||
# 本地执行时,把当前 run_id 写入上下文,便于更深层的 MCP/tool 事件挂父节点。
|
||||
with process_run_context(process_run_id):
|
||||
return await self.local_executor.run_local_task(
|
||||
task=task,
|
||||
label=label,
|
||||
agent_id=descriptor.id,
|
||||
agent_name=descriptor.name,
|
||||
system_prompt=descriptor.system_prompt,
|
||||
model=descriptor.model,
|
||||
progress_callback=event_callback,
|
||||
)
|
||||
if descriptor.kind == "a2a_remote" or descriptor.protocol == "a2a":
|
||||
# 远端执行交给 A2AClient,委派层只负责传递事件回调和 task_callback。
|
||||
with process_run_context(process_run_id):
|
||||
return await self.a2a_client.run_task(
|
||||
descriptor,
|
||||
task=task,
|
||||
label=label,
|
||||
event_callback=event_callback,
|
||||
task_callback=task_callback,
|
||||
)
|
||||
raise ValueError(f"Unsupported agent kind '{descriptor.kind}'")
|
||||
|
||||
def _build_progress_callback(
|
||||
self,
|
||||
origin: dict[str, str],
|
||||
descriptor: AgentDescriptor,
|
||||
event_run_id: str,
|
||||
tracking_run_id: str | None = None,
|
||||
publish_via_bus: bool = True,
|
||||
):
|
||||
"""构造统一的进度回调,适配本地 agent 和 A2A 流事件。"""
|
||||
last_text: str | None = None
|
||||
last_status: str | None = None
|
||||
|
||||
if descriptor.protocol == "a2a":
|
||||
async def _callback(event: A2AStreamEvent) -> None:
|
||||
nonlocal last_text, last_status
|
||||
# 远端一旦暴露 task_id,立刻登记,便于后续取消。
|
||||
if tracking_run_id and event.task_id:
|
||||
self._register_remote_task(tracking_run_id, descriptor, event.task_id)
|
||||
text = (event.text or "").strip()
|
||||
status = (event.status or "").strip()
|
||||
if text and text != last_text:
|
||||
last_text = text
|
||||
# 文本进度既发给 bus,也发结构化 process event。
|
||||
await self._publish_prefixed_progress(
|
||||
origin,
|
||||
descriptor,
|
||||
text,
|
||||
publish_via_bus=publish_via_bus,
|
||||
)
|
||||
await emit_process_event(
|
||||
"process_run_progress",
|
||||
run_id=event_run_id,
|
||||
actor_type="agent",
|
||||
actor_id=descriptor.id,
|
||||
actor_name=descriptor.name,
|
||||
text=text,
|
||||
metadata={"kind": event.kind, "protocol": "a2a"},
|
||||
)
|
||||
if event.kind == "artifact-update":
|
||||
# artifact-update 单独再抛一份 artifact 事件,前端可按附件样式渲染。
|
||||
await emit_process_event(
|
||||
"process_run_artifact",
|
||||
run_id=event_run_id,
|
||||
actor_type="agent",
|
||||
actor_id=descriptor.id,
|
||||
actor_name=descriptor.name,
|
||||
title=f"{descriptor.name} artifact",
|
||||
artifact_type="text",
|
||||
content=text,
|
||||
metadata={"kind": event.kind, "protocol": "a2a"},
|
||||
)
|
||||
if status and status != last_status:
|
||||
last_status = status
|
||||
# A2A 的原始状态名不稳定,这里统一归一化后再发给前端。
|
||||
await emit_process_event(
|
||||
"process_run_status",
|
||||
run_id=event_run_id,
|
||||
actor_type="agent",
|
||||
actor_id=descriptor.id,
|
||||
actor_name=descriptor.name,
|
||||
status=self._ui_status(status),
|
||||
text=f"{descriptor.name}: {status}",
|
||||
metadata={"raw_status": status, "protocol": "a2a"},
|
||||
)
|
||||
|
||||
return _callback
|
||||
|
||||
async def _local_callback(text: str, *, tool_hint: bool = False) -> None:
|
||||
nonlocal last_text, last_status
|
||||
clean = text.strip()
|
||||
if clean and clean != last_text:
|
||||
last_text = clean
|
||||
await self._publish_prefixed_progress(
|
||||
origin,
|
||||
descriptor,
|
||||
clean,
|
||||
publish_via_bus=publish_via_bus,
|
||||
tool_hint=tool_hint,
|
||||
)
|
||||
await emit_process_event(
|
||||
"process_run_progress",
|
||||
run_id=event_run_id,
|
||||
actor_type="agent",
|
||||
actor_id=descriptor.id,
|
||||
actor_name=descriptor.name,
|
||||
text=clean,
|
||||
metadata={"tool_hint": tool_hint, "protocol": "local"},
|
||||
)
|
||||
status = "running"
|
||||
if status != last_status:
|
||||
last_status = status
|
||||
# 本地执行没有像 A2A 那样细粒度状态流,至少发一次 running 状态。
|
||||
await emit_process_event(
|
||||
"process_run_status",
|
||||
run_id=event_run_id,
|
||||
actor_type="agent",
|
||||
actor_id=descriptor.id,
|
||||
actor_name=descriptor.name,
|
||||
status=status,
|
||||
text=f"{descriptor.name} is working",
|
||||
metadata={"protocol": "local"},
|
||||
)
|
||||
|
||||
return _local_callback
|
||||
|
||||
def _build_task_callback(self, run_id: str, descriptor: AgentDescriptor):
|
||||
"""为远端 A2A agent 构造 task_id 登记回调。"""
|
||||
if descriptor.protocol != "a2a":
|
||||
return None
|
||||
|
||||
async def _callback(task_id: str) -> None:
|
||||
self._register_remote_task(run_id, descriptor, task_id)
|
||||
|
||||
return _callback
|
||||
|
||||
def _register_remote_task(
|
||||
self,
|
||||
run_id: str,
|
||||
descriptor: AgentDescriptor,
|
||||
task_id: str,
|
||||
) -> None:
|
||||
"""把远端 agent 产生的 task_id 记到运行状态里。"""
|
||||
state = self._running_tasks.get(run_id)
|
||||
if state is None:
|
||||
return
|
||||
state.remote_agents[descriptor.id] = descriptor
|
||||
state.remote_task_ids[descriptor.id] = task_id
|
||||
|
||||
async def _cancel_remote_tasks(self, run_id: str, state: DelegationRun) -> None:
|
||||
"""尽力取消当前委派对应的所有远端 A2A 任务。"""
|
||||
if not state.remote_task_ids:
|
||||
return
|
||||
|
||||
async def _cancel_one(agent_id: str, task_id: str) -> tuple[str, bool]:
|
||||
descriptor = state.remote_agents.get(agent_id)
|
||||
if descriptor is None:
|
||||
return agent_id, False
|
||||
try:
|
||||
cancelled = await self.a2a_client.cancel_task(descriptor, task_id)
|
||||
return agent_id, cancelled
|
||||
except Exception as exc:
|
||||
# 取消失败只记日志,不阻断其他任务的取消尝试。
|
||||
logger.warning("Failed to cancel remote task {} for {}: {}", task_id, agent_id, exc)
|
||||
return agent_id, False
|
||||
|
||||
results = await asyncio.gather(*[
|
||||
_cancel_one(agent_id, task_id)
|
||||
for agent_id, task_id in list(state.remote_task_ids.items())
|
||||
])
|
||||
for agent_id, cancelled in results:
|
||||
if cancelled:
|
||||
logger.info("Cancelled remote A2A task for {} in delegation {}", agent_id, run_id)
|
||||
|
||||
async def _announce_cancelled(
|
||||
self,
|
||||
run_id: str,
|
||||
label: str,
|
||||
task: str,
|
||||
origin: dict[str, str],
|
||||
*,
|
||||
announce_via_bus: bool,
|
||||
) -> None:
|
||||
"""公告委派被取消。"""
|
||||
if announce_via_bus:
|
||||
await self._publish_announcement(
|
||||
(
|
||||
f"[Delegation '{label}' cancelled]\n\n"
|
||||
f"Task: {task}\n\n"
|
||||
"Tell the user briefly that the delegated work was cancelled."
|
||||
),
|
||||
origin,
|
||||
sender_id="delegation-cancel",
|
||||
)
|
||||
await self._emit_direct_user_message(
|
||||
f"The delegated work '{label}' for task '{task}' was cancelled. Tell the user briefly.",
|
||||
f"已取消委派任务:{label}",
|
||||
)
|
||||
|
||||
async def _announce_single_result(
|
||||
self,
|
||||
run_id: str,
|
||||
label: str,
|
||||
task: str,
|
||||
result: AgentRunResult,
|
||||
origin: dict[str, str],
|
||||
*,
|
||||
announce_via_bus: bool,
|
||||
) -> None:
|
||||
"""公告单 agent 委派结果。"""
|
||||
status_text = "completed successfully" if result.status == "ok" else result.status
|
||||
content = (
|
||||
f"[Delegation '{label}' {status_text}]\n\n"
|
||||
f"Agent: {result.agent_name} ({result.agent_id})\n"
|
||||
f"Task: {task}\n\n"
|
||||
f"Result:\n{result.summary}\n\n"
|
||||
"Summarize this naturally for the user. Keep it brief (1-2 sentences). "
|
||||
"Do not mention technical details like task IDs unless they matter."
|
||||
)
|
||||
if announce_via_bus:
|
||||
await self._publish_announcement(content, origin, sender_id="delegation")
|
||||
await self._emit_direct_user_message(
|
||||
content,
|
||||
f"{result.agent_name} 已完成:{result.summary}",
|
||||
)
|
||||
logger.debug("Delegation [{}] announced result", run_id)
|
||||
|
||||
async def _announce_group_result(
|
||||
self,
|
||||
run_id: str,
|
||||
label: str,
|
||||
task: str,
|
||||
results: list[AgentRunResult],
|
||||
origin: dict[str, str],
|
||||
*,
|
||||
announce_via_bus: bool,
|
||||
) -> None:
|
||||
"""公告 group delegation 汇总结果。"""
|
||||
lines = [f"[Agent group '{label}' completed]", "", f"Task: {task}", "", "Members:"]
|
||||
for result in results:
|
||||
lines.append(f"- {result.agent_name} ({result.agent_id}): {result.status}")
|
||||
lines.extend(["", "Results:"])
|
||||
for result in results:
|
||||
lines.append(f"### {result.agent_name} ({result.status})")
|
||||
lines.append(result.summary)
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Summarize this naturally for the user. Mention disagreements or failures if any."
|
||||
)
|
||||
summary = "\n".join(lines).strip()
|
||||
if announce_via_bus:
|
||||
await self._publish_announcement(
|
||||
summary,
|
||||
origin,
|
||||
sender_id="delegation-group",
|
||||
)
|
||||
await self._emit_direct_user_message(
|
||||
summary,
|
||||
"多 agent 协作已完成,请查看各 agent 的结果与最终结论。",
|
||||
)
|
||||
logger.debug("Delegation group [{}] announced result", run_id)
|
||||
|
||||
async def _publish_announcement(
|
||||
self,
|
||||
content: str,
|
||||
origin: dict[str, str],
|
||||
sender_id: str,
|
||||
) -> None:
|
||||
"""通过 system inbound 消息把公告重新送回主 agent 链路。"""
|
||||
msg = InboundMessage(
|
||||
channel="system",
|
||||
sender_id=sender_id,
|
||||
chat_id=f"{origin['channel']}:{origin['chat_id']}",
|
||||
content=content,
|
||||
)
|
||||
await self.bus.publish_inbound(msg)
|
||||
766
app-instance/backend/nanobot/agent/loop.py
Normal file
766
app-instance/backend/nanobot/agent/loop.py
Normal file
@ -0,0 +1,766 @@
|
||||
"""Agent 主循环:nanobot 的核心处理引擎。
|
||||
|
||||
职责概览:
|
||||
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 SpawnTool
|
||||
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 是 nanobot 运行时的“对话编排器”。
|
||||
|
||||
一次标准处理链路:
|
||||
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,
|
||||
):
|
||||
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.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,
|
||||
)
|
||||
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,
|
||||
workspace=workspace,
|
||||
bus=bus,
|
||||
registry=self.agent_registry,
|
||||
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,
|
||||
)
|
||||
|
||||
# 运行时状态位。
|
||||
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())
|
||||
self.tools.register(MessageTool(send_callback=self.bus.publish_outbound))
|
||||
self.tools.register(SpawnTool(manager=self.delegation))
|
||||
|
||||
# 只有注入 cron_service 时才暴露 cron 工具,避免空引用。
|
||||
if self.cron_service:
|
||||
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)
|
||||
|
||||
# spawn 工具:子代理完成后需要把结果回投到原会话,
|
||||
# 因此只需记住来源 channel/chat_id。
|
||||
if spawn_tool := self.tools.get("spawn"):
|
||||
if isinstance(spawn_tool, SpawnTool):
|
||||
spawn_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="🐈 nanobot 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_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 ""
|
||||
582
app-instance/backend/nanobot/agent/marketplace.py
Normal file
582
app-instance/backend/nanobot/agent/marketplace.py
Normal file
@ -0,0 +1,582 @@
|
||||
"""Marketplace manager for nanobot — 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")
|
||||
143
app-instance/backend/nanobot/agent/memory.py
Normal file
143
app-instance/backend/nanobot/agent/memory.py
Normal 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
|
||||
291
app-instance/backend/nanobot/agent/plugins.py
Normal file
291
app-instance/backend/nanobot/agent/plugins.py
Normal file
@ -0,0 +1,291 @@
|
||||
"""Plugin system for nanobot - 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("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
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("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
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
|
||||
84
app-instance/backend/nanobot/agent/process_events.py
Normal file
84
app-instance/backend/nanobot/agent/process_events.py
Normal 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)
|
||||
22
app-instance/backend/nanobot/agent/run_result.py
Normal file
22
app-instance/backend/nanobot/agent/run_result.py
Normal file
@ -0,0 +1,22 @@
|
||||
"""委派执行结果的共享类型定义。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentRunResult:
|
||||
"""统一描述一次 agent 执行结果。"""
|
||||
|
||||
# 执行方的稳定 ID,适合程序判断和日志检索。
|
||||
agent_id: str
|
||||
# 展示给用户或前端时使用的人类可读名称。
|
||||
agent_name: str
|
||||
# 归一化状态:通常是 `ok` / `error` / `cancelled` 等。
|
||||
status: str
|
||||
# 面向上层的简要总结,是最终展示和二次总结的主要输入。
|
||||
summary: str
|
||||
# 可选原始载荷,保留底层协议返回值,便于调试或后续扩展。
|
||||
raw: dict[str, Any] | None = None
|
||||
238
app-instance/backend/nanobot/agent/skill_reviews.py
Normal file
238
app-instance/backend/nanobot/agent/skill_reviews.py
Normal 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"),
|
||||
}
|
||||
284
app-instance/backend/nanobot/agent/skills.py
Normal file
284
app-instance/backend/nanobot/agent/skills.py
Normal 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("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
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
|
||||
239
app-instance/backend/nanobot/agent/subagent.py
Normal file
239
app-instance/backend/nanobot/agent/subagent.py
Normal file
@ -0,0 +1,239 @@
|
||||
"""本地委派执行器。
|
||||
|
||||
这个类不再负责“后台任务管理”和“结果回流”,只保留一件事:
|
||||
在统一委派层要求执行本地任务时,提供一个受限工具集的本地 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
|
||||
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.web import WebFetchTool, WebSearchTool
|
||||
from nanobot.providers.base import LLMProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
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
|
||||
|
||||
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,
|
||||
) -> AgentRunResult:
|
||||
"""执行一次本地委派任务,并返回结构化结果。"""
|
||||
# 每次任务都新建一套局部工具注册表,避免不同任务之间共享临时状态。
|
||||
tools = self._build_local_tools()
|
||||
prompt = self._build_subagent_prompt(
|
||||
task,
|
||||
agent_name=agent_name,
|
||||
custom_system_prompt=system_prompt,
|
||||
)
|
||||
# 本地委派不共享主会话历史,只带“专用 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
|
||||
|
||||
if final_result is None:
|
||||
# 兜底避免出现“任务做完了但完全没文本”的空结果。
|
||||
final_result = "Task completed but no final response was generated."
|
||||
|
||||
return AgentRunResult(
|
||||
agent_id=agent_id,
|
||||
agent_name=agent_name,
|
||||
status="ok",
|
||||
summary=final_result,
|
||||
)
|
||||
|
||||
def _build_local_tools(self) -> 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())
|
||||
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,
|
||||
) -> 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 ""
|
||||
|
||||
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
|
||||
|
||||
## What You Can Do
|
||||
- Read and write files in the workspace
|
||||
- Execute shell commands
|
||||
- Search the web and fetch web pages
|
||||
- Complete the task thoroughly
|
||||
|
||||
## What You Cannot Do
|
||||
- Send messages directly to users (no message tool available)
|
||||
- Spawn other subagents
|
||||
- Access the main agent's conversation history
|
||||
|
||||
## Workspace
|
||||
Your workspace is at: {self.workspace}
|
||||
Skills are available at: {self.workspace}/skills/ (read SKILL.md files as needed)
|
||||
|
||||
When you have completed the task, provide a clear summary of your findings or actions.{extra}"""
|
||||
6
app-instance/backend/nanobot/agent/tools/__init__.py
Normal file
6
app-instance/backend/nanobot/agent/tools/__init__.py
Normal 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"]
|
||||
102
app-instance/backend/nanobot/agent/tools/base.py
Normal file
102
app-instance/backend/nanobot/agent/tools/base.py
Normal 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,
|
||||
}
|
||||
}
|
||||
246
app-instance/backend/nanobot/agent/tools/cron.py
Normal file
246
app-instance/backend/nanobot/agent/tools/cron.py
Normal 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"
|
||||
116
app-instance/backend/nanobot/agent/tools/cron_action.py
Normal file
116
app-instance/backend/nanobot/agent/tools/cron_action.py
Normal 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}"
|
||||
275
app-instance/backend/nanobot/agent/tools/filesystem.py
Normal file
275
app-instance/backend/nanobot/agent/tools/filesystem.py
Normal 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)}"
|
||||
346
app-instance/backend/nanobot/agent/tools/mcp.py
Normal file
346
app-instance/backend/nanobot/agent/tools/mcp.py
Normal file
@ -0,0 +1,346 @@
|
||||
"""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
|
||||
|
||||
|
||||
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 展示。
|
||||
report[name]["status"] = "error"
|
||||
report[name]["last_error"] = str(e)
|
||||
logger.error("MCP server '{}': failed to connect: {}", name, e)
|
||||
return report
|
||||
108
app-instance/backend/nanobot/agent/tools/message.py
Normal file
108
app-instance/backend/nanobot/agent/tools/message.py
Normal 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)}"
|
||||
96
app-instance/backend/nanobot/agent/tools/registry.py
Normal file
96
app-instance/backend/nanobot/agent/tools/registry.py
Normal 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
|
||||
284
app-instance/backend/nanobot/agent/tools/shell.py
Normal file
284
app-instance/backend/nanobot/agent/tools/shell.py
Normal 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()
|
||||
105
app-instance/backend/nanobot/agent/tools/spawn.py
Normal file
105
app-instance/backend/nanobot/agent/tools/spawn.py
Normal file
@ -0,0 +1,105 @@
|
||||
"""spawn 工具:用于把任务委派给后台 agent。"""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from nanobot.agent.tools.base import Tool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.agent.delegation import DelegationManager
|
||||
|
||||
|
||||
class SpawnTool(Tool):
|
||||
"""
|
||||
后台委派工具。
|
||||
|
||||
作用:
|
||||
1. 把耗时/可并行的任务委派给 DelegationManager;
|
||||
2. 目标可以是本地 agent、A2A 远端 agent 或 agent group;
|
||||
3. 后台任务异步执行,不阻塞当前对话回合。
|
||||
"""
|
||||
|
||||
def __init__(self, manager: "DelegationManager"):
|
||||
# manager 负责真正创建 asyncio 后台任务并管理生命周期。
|
||||
self._manager = manager
|
||||
# 默认来源会话(CLI 直连场景)。实际会在每轮由 loop._set_tool_context 覆盖。
|
||||
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:
|
||||
"""设置后台委派结果回传的目标会话。"""
|
||||
# 委派任务完成后并不会直接给用户发消息,
|
||||
# 而是把结果发回这里记录的 origin(channel/chat_id)对应会话。
|
||||
self._origin_channel = channel
|
||||
self._origin_chat_id = chat_id
|
||||
self._announce_via_bus = announce_via_bus
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
# 暴露给 LLM 的工具名;模型会用这个名字发起 function call。
|
||||
return "spawn"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
# 给模型看的能力描述,强调“后台执行 + 完成后回报”语义。
|
||||
return (
|
||||
"Delegate a task to a background agent. "
|
||||
"Use this for complex or time-consuming work that can run independently. "
|
||||
"You can target a specific agent, a group of agents, or let the system choose. "
|
||||
"The delegated agent(s) will report back when done."
|
||||
)
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
# OpenAI function schema:定义模型可传入的参数结构。
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task": {
|
||||
"type": "string",
|
||||
"description": "The task for the delegated agent to complete",
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "Optional short label for the task (for display)",
|
||||
},
|
||||
"target": {
|
||||
"type": "string",
|
||||
"description": "Optional agent ID or name for a single target",
|
||||
},
|
||||
"targets": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional list of agent IDs/names for a group task",
|
||||
},
|
||||
"strategy": {
|
||||
"type": "string",
|
||||
"enum": ["auto", "local", "plugin", "a2a", "group"],
|
||||
"description": "Routing strategy. Default is auto.",
|
||||
},
|
||||
},
|
||||
"required": ["task"],
|
||||
}
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
task: str,
|
||||
label: str | None = None,
|
||||
target: str | None = None,
|
||||
targets: list[str] | None = None,
|
||||
strategy: str = "auto",
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""创建并启动一个后台委派任务。"""
|
||||
# 这里仅负责转发请求,不在本工具内执行实际任务逻辑。
|
||||
# 返回值是“已启动”状态文本,真正结果稍后通过主消息总线回传。
|
||||
return await self._manager.dispatch(
|
||||
task=task,
|
||||
label=label,
|
||||
target=target,
|
||||
targets=targets,
|
||||
strategy=strategy,
|
||||
origin_channel=self._origin_channel,
|
||||
origin_chat_id=self._origin_chat_id,
|
||||
announce_via_bus=self._announce_via_bus,
|
||||
)
|
||||
163
app-instance/backend/nanobot/agent/tools/web.py
Normal file
163
app-instance/backend/nanobot/agent/tools/web.py
Normal 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))
|
||||
Reference in New Issue
Block a user