Hermes-agent

This commit is contained in:
Zakaria
2026-06-14 14:30:48 -04:00
commit dac4b88b94
5058 changed files with 1884848 additions and 0 deletions
@@ -0,0 +1,184 @@
---
sidebar_position: 2
title: "ACP 内部机制"
description: "ACP 适配器的工作原理:生命周期、会话、事件桥接、审批流程与工具渲染"
---
# ACP 内部机制
ACP 适配器将 Hermes 的同步 `AIAgent` 封装为异步 JSON-RPC stdio 服务器。
关键实现文件:
- `acp_adapter/entry.py`
- `acp_adapter/server.py`
- `acp_adapter/session.py`
- `acp_adapter/events.py`
- `acp_adapter/permissions.py`
- `acp_adapter/tools.py`
- `acp_adapter/auth.py`
- `acp_registry/agent.json`
## 启动流程
```text
hermes acp / hermes-acp / python -m acp_adapter
-> acp_adapter.entry.main()
-> parse --version / --check / --setup before server startup
-> load ~/.hermes/.env
-> configure stderr logging
-> construct HermesACPAgent
-> acp.run_agent(agent, use_unstable_protocol=True)
```
Zed ACP Registry 路径通过 `uvx --from 'hermes-agent[acp]==<version>' hermes-acp` 启动同一适配器,指向 `hermes-agent` PyPI 发布包。
stdout 保留用于 ACP JSON-RPC 传输。人类可读的日志输出至 stderr。
## 主要组件
### `HermesACPAgent`
`acp_adapter/server.py` 实现 ACP agent 协议。
职责:
- 初始化 / 认证
- 新建/加载/恢复/fork/列出/取消会话方法
- prompt(提示词)执行
- 会话模型切换
- 将同步 AIAgent 回调接入 ACP 异步通知
### `SessionManager`
`acp_adapter/session.py` 跟踪活跃的 ACP 会话。
每个会话存储:
- `session_id`
- `agent`
- `cwd`
- `model`
- `history`
- `cancel_event`
管理器线程安全,支持:
- create
- get
- remove
- fork
- list
- cleanup
- cwd 更新
### 事件桥接
`acp_adapter/events.py` 将 AIAgent 回调转换为 ACP `session_update` 事件。
已桥接的回调:
- `tool_progress_callback`
- `thinking_callback`(当前在 ACP 桥接中设置为 `None`——推理内容通过 `step_callback` 转发)
- `step_callback`
由于 `AIAgent` 在工作线程中运行,而 ACP I/O 位于主事件循环,桥接使用:
```python
asyncio.run_coroutine_threadsafe(...)
```
### 权限桥接
`acp_adapter/permissions.py` 将危险终端审批 prompt 适配为 ACP 权限请求。
映射关系:
- `allow_once` -> Hermes `once`
- `allow_always` -> Hermes `always`
- 拒绝选项 -> Hermes `deny`
超时和桥接失败默认拒绝。
### 工具渲染辅助
`acp_adapter/tools.py` 将 Hermes 工具映射到 ACP 工具类型,并构建面向编辑器的内容。
示例:
- `patch` / `write_file` -> 文件 diff
- `terminal` -> shell 命令文本
- `read_file` / `search_files` -> 文本预览
- 大型结果 -> 截断文本块(保障 UI 安全)
## 会话生命周期
```text
new_session(cwd)
-> create SessionState
-> create AIAgent(platform="acp", enabled_toolsets=["hermes-acp"])
-> bind task_id/session_id to cwd override
prompt(..., session_id)
-> extract text from ACP content blocks
-> reset cancel event
-> install callbacks + approval bridge
-> run AIAgent in ThreadPoolExecutor
-> update session history
-> emit final agent message chunk
```
### 取消
`cancel(session_id)`
- 设置会话取消事件
- 在可用时调用 `agent.interrupt()`
- 使 prompt 响应返回 `stop_reason="cancelled"`
### Fork
`fork_session()` 将消息历史深拷贝至新的活跃会话,在保留对话状态的同时为 fork 分配独立的 session ID 和 cwd。
## Provider/认证行为
ACP 不实现自己的认证存储。
而是复用 Hermes 的运行时解析器:
- `acp_adapter/auth.py`
- `hermes_cli/runtime_provider.py`
因此 ACP 通告并使用当前配置的 Hermes provider/凭据。它还始终通告一个终端 setup 认证方法(`hermes-setup`,参数 `--setup`),以便首次运行的 registry 客户端在启动正常 ACP 会话前可以打开 Hermes 的交互式模型/provider 配置。
## 工作目录绑定
ACP 会话携带编辑器 cwd。
会话管理器通过任务作用域的终端/文件覆盖将该 cwd 绑定到 ACP session ID,使文件和终端工具相对于编辑器工作区运行。
## 重复同名工具调用
事件桥接按工具名称以 FIFO 队列跟踪工具 ID,而非每个名称仅保留一个 ID。这对以下场景至关重要:
- 并行同名调用
- 单步内重复同名调用
若不使用 FIFO 队列,完成事件将附加到错误的工具调用上。
## 审批回调恢复
ACP 在 prompt 执行期间临时在终端工具上安装审批回调,执行完成后恢复之前的回调。这避免了将 ACP 会话特定的审批处理器永久全局安装。
## 当前限制
- ACP 会话持久化至共享的 `~/.hermes/state.db`(SessionDB),在进程重启后透明恢复;它们会出现在 `session_search`
- 非文本 prompt 块在请求文本提取时当前被忽略
- 编辑器特定的 UX 因 ACP 客户端实现而异
## 相关文件
- `tests/acp/` — ACP 测试套件
- `toolsets.py``hermes-acp` toolset 定义
- `hermes_cli/main.py``hermes acp` CLI 子命令
- `pyproject.toml``[acp]` 可选依赖 + `hermes-acp` 脚本
@@ -0,0 +1,688 @@
---
sidebar_position: 9
---
# 添加平台适配器
本指南介绍如何向 Hermes gateway 添加新的消息平台。平台适配器将 Hermes 连接到外部消息服务(Telegram、Discord、WeCom 等),使用户可以通过该服务与 agent 交互。
:::tip
添加平台有两种方式:
- **Plugin**(推荐用于社区/第三方):将 plugin 目录放入 `~/.hermes/plugins/` — 无需修改任何核心代码。参见下方 [Plugin 路径](#plugin-path-recommended)。
- **内置**:需修改代码、配置和文档共 20+ 个文件。参见下方 [内置清单](#step-by-step-checklist)。
:::
## 架构概览
```
用户 ↔ 消息平台 ↔ 平台适配器 ↔ Gateway Runner ↔ AIAgent
```
每个适配器都继承自 `gateway/platforms/base.py` 中的 `BasePlatformAdapter`,并实现以下方法:
- **`connect()`** — 建立连接(WebSocket、长轮询、HTTP 服务器等)*(抽象方法)*
- **`disconnect()`** — 清理关闭 *(抽象方法)*
- **`send()`** — 向聊天发送文本消息 *(抽象方法)*
- **`send_typing()`** — 显示正在输入指示器(可选覆盖)
- **`get_chat_info()`** — 返回聊天元数据(可选覆盖)
适配器接收入站消息后,通过 `self.handle_message(event)` 转发,基类将其路由到 gateway runner。
## Plugin 路径(推荐){#plugin-path-recommended}
Plugin 系统允许你在不修改任何 Hermes 核心代码的情况下添加平台适配器。你的 plugin 是一个包含两个文件的目录:
```
~/.hermes/plugins/my-platform/
plugin.yaml # Plugin 元数据
adapter.py # 适配器类 + register() 入口点
```
### plugin.yaml
Plugin 元数据。`requires_env``optional_env` 块会自动填充 `hermes config` UI 条目(参见下方[在 hermes config 中暴露环境变量](#surfacing-env-vars-in-hermes-config))。
```yaml
name: my-platform
label: My Platform
kind: platform
version: 1.0.0
description: My custom messaging platform adapter
author: Your Name
requires_env:
- MY_PLATFORM_TOKEN # 裸字符串有效
- name: MY_PLATFORM_CHANNEL # 或使用富字典以获得更好的 UX
description: "Channel to join"
prompt: "Channel"
password: false
optional_env:
- name: MY_PLATFORM_HOME_CHANNEL
description: "Default channel for cron delivery"
password: false
```
### adapter.py
```python
import os
from gateway.platforms.base import (
BasePlatformAdapter, SendResult, MessageEvent, MessageType,
)
from gateway.config import Platform, PlatformConfig
class MyPlatformAdapter(BasePlatformAdapter):
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform("my_platform"))
extra = config.extra or {}
self.token = os.getenv("MY_PLATFORM_TOKEN") or extra.get("token", "")
async def connect(self) -> bool:
# 连接到平台 API,启动监听器
self._mark_connected()
return True
async def disconnect(self) -> None:
self._mark_disconnected()
async def send(self, chat_id, content, reply_to=None, metadata=None):
# 通过平台 API 发送消息
return SendResult(success=True, message_id="...")
async def get_chat_info(self, chat_id):
return {"name": chat_id, "type": "dm"}
def check_requirements() -> bool:
return bool(os.getenv("MY_PLATFORM_TOKEN"))
def validate_config(config) -> bool:
extra = getattr(config, "extra", {}) or {}
return bool(os.getenv("MY_PLATFORM_TOKEN") or extra.get("token"))
def _env_enablement() -> dict | None:
token = os.getenv("MY_PLATFORM_TOKEN", "").strip()
channel = os.getenv("MY_PLATFORM_CHANNEL", "").strip()
if not (token and channel):
return None
seed = {"token": token, "channel": channel}
home = os.getenv("MY_PLATFORM_HOME_CHANNEL")
if home:
seed["home_channel"] = {"chat_id": home, "name": "Home"}
return seed
def register(ctx):
"""Plugin 入口点 — 由 Hermes plugin 系统调用。"""
ctx.register_platform(
name="my_platform",
label="My Platform",
adapter_factory=lambda cfg: MyPlatformAdapter(cfg),
check_fn=check_requirements,
validate_config=validate_config,
required_env=["MY_PLATFORM_TOKEN"],
install_hint="pip install my-platform-sdk",
# 环境变量驱动的自动配置 — 在适配器构建前从环境变量
# 填充 PlatformConfig.extra。参见下方"环境变量驱动的自动配置"章节。
env_enablement_fn=_env_enablement,
# Cron 主频道投递支持。允许 deliver=my_platform 的 cron 任务
# 无需编辑 cron/scheduler.py 即可路由。参见下方"Cron 投递"章节。
cron_deliver_env_var="MY_PLATFORM_HOME_CHANNEL",
# 每平台用户授权环境变量
allowed_users_env="MY_PLATFORM_ALLOWED_USERS",
allow_all_env="MY_PLATFORM_ALLOW_ALL_USERS",
# 智能分块的消息长度限制(0 = 无限制)
max_message_length=4000,
# 注入系统 prompt(提示词)的 LLM 指导
platform_hint=(
"You are chatting via My Platform. "
"It supports markdown formatting."
),
# 显示
emoji="💬",
)
# 可选:注册平台专属工具
ctx.register_tool(
name="my_platform_search",
toolset="my_platform",
schema={...},
handler=my_search_handler,
)
```
### 配置
用户在 `config.yaml` 中配置平台:
```yaml
gateway:
platforms:
my_platform:
enabled: true
extra:
token: "..."
channel: "#general"
```
或通过环境变量(适配器在 `__init__` 中读取)。
### Plugin 系统自动处理的内容
调用 `ctx.register_platform()` 时,以下集成点将自动处理 — 无需修改核心代码:
| 集成点 | 工作方式 |
|---|---|
| Gateway 适配器创建 | 在内置 if/elif 链之前检查注册表 |
| 配置解析 | `Platform._missing_()` 接受任意平台名称 |
| 已连接平台验证 | 调用注册表中的 `validate_config()` |
| 用户授权 | 检查 `allowed_users_env` / `allow_all_env` |
| 仅环境变量自动启用 | `env_enablement_fn` 填充 `PlatformConfig.extra` + `home_channel` |
| YAML 配置桥接 | `apply_yaml_config_fn``config.yaml` 键转换为环境变量/extras |
| Cron 投递 | `cron_deliver_env_var` 使 `deliver=<name>` 生效 |
| `hermes config` UI 条目 | `plugin.yaml` 中的 `requires_env` / `optional_env` 自动填充 |
| send_message 工具 | 通过实时 gateway 适配器路由 |
| Webhook 跨平台投递 | 检查注册表中的已知平台 |
| `/update` 命令访问 | `allow_update_command` 标志 |
| 频道目录 | Plugin 平台包含在枚举中 |
| 系统 prompt 提示 | `platform_hint` 注入 LLM 上下文 |
| 消息分块 | `max_message_length` 用于智能分割 |
| PII 脱敏 | `pii_safe` 标志 |
| `hermes status` | 显示带 `(plugin)` 标签的 plugin 平台 |
| `hermes gateway setup` | Plugin 平台出现在设置菜单中 |
| `hermes tools` / `hermes skills` | Plugin 平台出现在每平台配置中 |
| Token 锁(多配置文件) | 在 `connect()` 中使用 `acquire_scoped_lock()` |
| 孤立配置警告 | Plugin 缺失时输出描述性日志 |
## 环境变量驱动的自动配置
大多数用户通过将环境变量写入 `~/.hermes/.env` 来配置平台,而不是编辑 `config.yaml``env_enablement_fn` hook 允许你的 plugin 在适配器构建**之前**读取这些环境变量,使 `hermes gateway status``get_connected_platforms()` 和 cron 投递无需实例化平台 SDK 即可看到正确状态。
```python
def _env_enablement() -> dict | None:
"""从环境变量填充 PlatformConfig.extra。
在 load_gateway_config() 期间由平台注册表调用。
当平台未完成最低配置时返回 None — 调用方将跳过自动启用。
返回字典以填充 extras。
特殊键 'home_channel' 会被提取并成为 PlatformConfig 上的
HomeChannel dataclass;其他所有键合并到 PlatformConfig.extra 中。
"""
token = os.getenv("MY_PLATFORM_TOKEN", "").strip()
channel = os.getenv("MY_PLATFORM_CHANNEL", "").strip()
if not (token and channel):
return None
seed = {"token": token, "channel": channel}
home = os.getenv("MY_PLATFORM_HOME_CHANNEL")
if home:
seed["home_channel"] = {
"chat_id": home,
"name": os.getenv("MY_PLATFORM_HOME_CHANNEL_NAME", "Home"),
}
return seed
def register(ctx):
ctx.register_platform(
name="my_platform",
label="My Platform",
adapter_factory=lambda cfg: MyPlatformAdapter(cfg),
check_fn=check_requirements,
validate_config=validate_config,
env_enablement_fn=_env_enablement,
# ... 其他字段
)
```
## YAML→env 配置桥接
部分用户更倾向于设置 `config.yaml` 键(`my_platform.require_mention``my_platform.allowed_channels` 等)而非环境变量。`apply_yaml_config_fn` hook 允许你的 plugin 自行处理这一转换,而无需强制核心 `gateway/config.py` 了解你平台的 YAML schema。
```python
import os
def _apply_yaml_config(yaml_cfg: dict, platform_cfg: dict) -> dict | None:
"""将 config.yaml 中的 `my_platform:` 键转换为环境变量/extras。
yaml_cfg — 完整的顶层解析后 config.yaml 字典
platform_cfg — 平台自身的子字典(yaml_cfg.get("my_platform", {})
可直接修改 os.environ(使用 `not os.getenv(...)` 守卫以保持
环境变量 > YAML 的优先级),也可返回字典合并到 PlatformConfig.extra 中。
返回 None 或 {} 表示无额外内容。
"""
if "require_mention" in platform_cfg and not os.getenv("MY_PLATFORM_REQUIRE_MENTION"):
os.environ["MY_PLATFORM_REQUIRE_MENTION"] = str(platform_cfg["require_mention"]).lower()
allowed = platform_cfg.get("allowed_channels")
if allowed is not None and not os.getenv("MY_PLATFORM_ALLOWED_CHANNELS"):
if isinstance(allowed, list):
allowed = ",".join(str(v) for v in allowed)
os.environ["MY_PLATFORM_ALLOWED_CHANNELS"] = str(allowed)
return None # 无需合并到 PlatformConfig.extra 的额外内容
def register(ctx):
ctx.register_platform(
name="my_platform",
...,
apply_yaml_config_fn=_apply_yaml_config,
)
```
该 hook 在 `load_gateway_config()` 期间,于通用共享键循环(处理 `unauthorized_dm_behavior``notice_delivery``reply_prefix``require_mention` 等公共键)之后、`_apply_env_overrides()` 之前调用,因此你的 plugin 只需桥接**平台专属**键。
hook 内抛出的异常会被捕获并以 debug 级别记录 — 行为异常的 plugin 不会中止 gateway 配置加载。
## Cron 投递
要让 `deliver=my_platform` 的 cron 任务路由到已配置的主频道,将 `cron_deliver_env_var` 设置为持有默认聊天/房间/频道 ID 的环境变量名:
```python
ctx.register_platform(
name="my_platform",
...
cron_deliver_env_var="MY_PLATFORM_HOME_CHANNEL",
)
```
调度器在解析 `deliver=my_platform` 任务的主目标时会读取此环境变量,并将该平台视为 `_KNOWN_DELIVERY_PLATFORMS` 风格检查中的有效 cron 目标。如果你的 `env_enablement_fn` 填充了 `home_channel` 字典(见上文),则优先使用该值 — `cron_deliver_env_var` 是在环境变量填充之前运行的 cron 任务的回退方案。
### 进程外 cron 投递
`cron_deliver_env_var` 使你的平台成为可识别的 `deliver=` 目标。要在 cron 任务运行于独立进程(即 `hermes cron run``hermes gateway` 分离)时使实际发送成功,需注册 `standalone_sender_fn`
```python
async def _standalone_send(
pconfig,
chat_id,
message,
*,
thread_id=None,
media_files=None,
force_document=False,
):
"""建立临时连接/获取新 token,发送消息,然后关闭。"""
# ... 建立连接,发送消息,返回结果 ...
return {"success": True, "message_id": "..."}
# 或 {"error": "..."}
ctx.register_platform(
name="my_platform",
...
cron_deliver_env_var="MY_PLATFORM_HOME_CHANNEL",
standalone_sender_fn=_standalone_send,
)
```
为何需要此 hook:内置平台(Telegram、Discord、Slack 等)在 `tools/send_message_tool.py` 中内置了直接 REST 辅助函数,使 cron 无需在同一进程中持有 gateway 即可投递。Plugin 平台历史上依赖 `_gateway_runner_ref()`,该函数在 gateway 进程外返回 `None`,因此若没有 `standalone_sender_fn`cron 端发送会失败并报 `No live adapter for platform '<name>'`
该函数接收与实时适配器相同的 `pconfig``chat_id`,以及可选的 `thread_id``media_files``force_document` 关键字参数。返回 `{"success": True, "message_id": ...}` 视为成功投递;返回 `{"error": "..."}` 会将消息记录到 cron 的 `delivery_errors` 中。函数内抛出的异常由调度器捕获并报告为 `Plugin standalone send failed: <reason>`。参考实现位于 `plugins/platforms/{irc,teams,google_chat}/adapter.py`
## 在 `hermes config` 中暴露环境变量 {#surfacing-env-vars-in-hermes-config}
`hermes_cli/config.py` 在导入时扫描 `plugins/platforms/*/plugin.yaml`,并从 `requires_env` 和(可选的)`optional_env` 块自动填充 `OPTIONAL_ENV_VARS`。使用富字典形式可提供完整的描述、prompt、password 标志和 URL — CLI 设置 UI 会自动识别。
```yaml
# plugins/platforms/my_platform/plugin.yaml
name: my_platform-platform
label: My Platform
kind: platform
version: 1.0.0
description: >
My Platform gateway adapter for Hermes Agent.
author: Your Name
requires_env:
- name: MY_PLATFORM_TOKEN
description: "Bot API token from the My Platform console"
prompt: "My Platform bot token"
url: "https://my-platform.example.com/bots"
password: true
- name: MY_PLATFORM_CHANNEL
description: "Channel to join (e.g. #hermes)"
prompt: "Channel"
password: false
optional_env:
- name: MY_PLATFORM_HOME_CHANNEL
description: "Default channel for cron delivery (defaults to MY_PLATFORM_CHANNEL)"
prompt: "Home channel (or empty)"
password: false
- name: MY_PLATFORM_ALLOWED_USERS
description: "Comma-separated user IDs allowed to talk to the bot"
prompt: "Allowed users (comma-separated)"
password: false
```
**支持的字典键:** `name`(必填)、`description``prompt``url``password`(布尔值;当省略时根据 `*_TOKEN` / `*_SECRET` / `*_KEY` / `*_PASSWORD` / `*_JSON` 后缀自动检测)、`category`(默认为 `"messaging"`)。
裸字符串条目(`- MY_PLATFORM_TOKEN`)仍然有效 — 会根据 plugin 的 `label` 自动生成通用描述。如果 `OPTIONAL_ENV_VARS` 中已存在同名变量的硬编码条目,则以硬编码为准(向后兼容);plugin.yaml 形式作为回退。
## 平台专属慢速 LLM 用户体验
某些平台存在约束,影响慢速 LLM 响应的呈现方式:
- **LINE** 发出单次使用的*回复 token*,在入站事件后约 60 秒过期。使用该 token 回复是免费的;回退到计费的 Push API 则不然。如果 LLM 在截止时间前未完成,选择是"消耗付费 Push 配额"或"在回复 token 过期前用它做些更聪明的事"。
- **WhatsApp** 在 24 小时不活跃后将会话标记为非活跃,此后只接受模板消息。
- **SMS** 没有正在输入指示器或渐进式更新的概念 — 长响应看起来就像 bot 离线了。
这些是 `BasePlatformAdapter` 无法预判的真实约束。Plugin 接口有意为适配器在基础输入循环之上叠加平台专属 UX 留出空间,而无需扩展 kwarg 列表。
### 模式:子类化 `_keep_typing` 以叠加飞行中 UX
`BasePlatformAdapter._keep_typing` 是正在输入指示器的心跳 — 它在 LLM 生成时作为后台任务运行,响应投递后被取消。要在某个阈值时叠加平台专属行为(例如在 45 秒时发送"仍在思考"气泡),在你的适配器中覆盖 `_keep_typing`,在 `super()._keep_typing()` 旁边调度你自己的任务,并在 `finally` 中清理:
```python
class LineAdapter(BasePlatformAdapter):
async def _keep_typing(self, chat_id: str, *args, **kwargs) -> None:
if self.slow_response_threshold <= 0:
await super()._keep_typing(chat_id, *args, **kwargs)
return
async def _fire_at_threshold() -> None:
try:
await asyncio.sleep(self.slow_response_threshold)
except asyncio.CancelledError:
raise
# 平台专属操作 — 对于 LINE,使用缓存的回复 token 发送
# Template Buttons "获取答案"气泡,用户可通过 postback
# 回调中的新(免费)回复 token 稍后获取缓存的响应。
await self._send_slow_response_button(chat_id)
side_task = asyncio.create_task(_fire_at_threshold())
try:
await super()._keep_typing(chat_id, *args, **kwargs)
finally:
if not side_task.done():
side_task.cancel()
try:
await side_task
except (asyncio.CancelledError, Exception):
pass
```
关键点:
- **始终 `await super()._keep_typing(...)`。** 输入心跳本身有独立价值 — 不要替换它,而是在其上叠加。
- **在 `finally` 中清理副任务。** 当 LLM 完成(或 `/stop` 取消运行)时,gateway 会取消输入任务。你的副任务也必须响应该取消,否则它会残留并可能在响应已投递后触发。
- **配合 `interrupt_session_activity`** 在用户发出 `/stop` 时解决任何孤立 UX 状态。对于 LINE,这意味着将 postback 缓存条目从 `PENDING` 转换为 `ERROR`,使持久的"获取答案"按钮投递"运行已中断"消息而非循环。
### 模式:子类化 `send` 以通过缓存路由而非立即发送
如果你的慢速响应 UX 缓存响应以供稍后检索(LINE 的 postback 流程),你的 `send` 覆盖需要识别三种模式:
1. **此聊天存在待处理的 postback** → 将响应缓存在 request_id 下,不发送任何可见内容。
2. **系统忙碌确认**`⚡ Interrupting``⏳ Queued``⏩ Steered`)→ 绕过缓存直接发送,使用户看到 gateway 对其输入的响应。
3. **正常响应** → 按常规通过回复 token 或 Push 发送。
```python
async def send(self, chat_id: str, content: str, **kw) -> SendResult:
if _is_system_bypass(content):
return await self._send_text_chunks(chat_id, content, force_push=False)
pending_rid = self._pending_buttons.get(chat_id)
if pending_rid:
self._cache.set_ready(pending_rid, content)
return SendResult(success=True, message_id=pending_rid)
return await self._send_text_chunks(chat_id, content, force_push=False)
```
`_SYSTEM_BYPASS_PREFIXES` 是 gateway 自身的忙碌确认前缀(`⚡``⏳``⏩``💾`)。无论缓存 UX 状态如何,始终让这些前缀可见地通过。
### 何时适用此模式
在以下情况使用输入循环覆盖方式:
- 平台的出站 API 存在硬性时间窗口约束(单次使用回复 token、过期的粘性会话等),**且**
- 在该平台上*可见的飞行中气泡*是可接受的 UX。
在以下情况使用更简单的 `slow_response_threshold = 0` 始终 Push 路径:
- 平台没有有意义的免费与付费区别,**或**
- 用户社区更倾向于"加载中……加载中……完成"的静默后响应,而非交互式中间气泡。
LINE 两者都支持:阈值默认为 45 秒用于免费 postback 获取,`LINE_SLOW_RESPONSE_THRESHOLD=0` 恢复为"始终 Push 回退"。
### 参考实现
完整的 LINE postback 实现参见 `plugins/platforms/line/adapter.py` — 包含 `RequestCache` 状态机(`PENDING → READY → DELIVERED`,以及 `/stop``ERROR`)、在阈值时触发 Template Buttons 气泡的 `_keep_typing` 覆盖、通过缓存路由的 `send` 覆盖,以及解决孤立 PENDING 条目的 `interrupt_session_activity` 覆盖。
### 参考实现(Plugin 路径)
完整的工作示例参见仓库中的 `plugins/platforms/irc/` — 一个无外部依赖的完整异步 IRC 适配器。`plugins/platforms/teams/` 涵盖 Bot Framework / Adaptive Cards`plugins/platforms/google_chat/` 涵盖基于 OAuth 的 REST API`plugins/platforms/line/` 涵盖带平台专属慢速 LLM UX 的 webhook 驱动消息 API。
---
## 分步清单(内置路径){#step-by-step-checklist}
:::note
此清单用于将平台直接添加到 Hermes 核心代码库 — 通常由核心贡献者为官方支持的平台执行。社区/第三方平台应使用上方的 [Plugin 路径](#plugin-path-recommended)。
:::
### 1. Platform 枚举
`gateway/config.py``Platform` 枚举中添加你的平台:
```python
class Platform(str, Enum):
# ... 现有平台 ...
NEWPLAT = "newplat"
```
### 2. 适配器文件
创建 `gateway/platforms/newplat.py`
```python
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
BasePlatformAdapter, MessageEvent, MessageType, SendResult,
)
def check_newplat_requirements() -> bool:
"""如果依赖可用则返回 True。"""
return SOME_SDK_AVAILABLE
class NewPlatAdapter(BasePlatformAdapter):
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.NEWPLAT)
# 从 config.extra 字典读取配置
extra = config.extra or {}
self._api_key = extra.get("api_key") or os.getenv("NEWPLAT_API_KEY", "")
async def connect(self) -> bool:
# 建立连接,启动轮询/webhook
self._mark_connected()
return True
async def disconnect(self) -> None:
self._running = False
self._mark_disconnected()
async def send(self, chat_id, content, reply_to=None, metadata=None):
# 通过平台 API 发送消息
return SendResult(success=True, message_id="...")
async def get_chat_info(self, chat_id):
return {"name": chat_id, "type": "dm"}
```
对于入站消息,构建 `MessageEvent` 并调用 `self.handle_message(event)`
```python
source = self.build_source(
chat_id=chat_id,
chat_name=name,
chat_type="dm", # 或 "group"
user_id=user_id,
user_name=user_name,
)
event = MessageEvent(
text=content,
message_type=MessageType.TEXT,
source=source,
message_id=msg_id,
)
await self.handle_message(event)
```
### 3. Gateway 配置(`gateway/config.py`
三个接触点:
1. **`get_connected_platforms()`** — 添加对你平台所需凭据的检查
2. **`load_gateway_config()`** — 添加 token 环境变量映射条目:`Platform.NEWPLAT: "NEWPLAT_TOKEN"`
3. **`_apply_env_overrides()`** — 将所有 `NEWPLAT_*` 环境变量映射到配置
### 4. Gateway Runner`gateway/run.py`
五个接触点:
1. **`_create_adapter()`** — 添加 `elif platform == Platform.NEWPLAT:` 分支
2. **`_is_user_authorized()` allowed_users 映射** — `Platform.NEWPLAT: "NEWPLAT_ALLOWED_USERS"`
3. **`_is_user_authorized()` allow_all 映射** — `Platform.NEWPLAT: "NEWPLAT_ALLOW_ALL_USERS"`
4. **早期环境检查 `_any_allowlist` 元组** — 添加 `"NEWPLAT_ALLOWED_USERS"`
5. **早期环境检查 `_allow_all` 元组** — 添加 `"NEWPLAT_ALLOW_ALL_USERS"`
6. **`_UPDATE_ALLOWED_PLATFORMS` frozenset** — 添加 `Platform.NEWPLAT`
### 5. 跨平台投递
1. **`gateway/platforms/webhook.py`** — 将 `"newplat"` 添加到投递类型元组
2. **`cron/scheduler.py`** — 添加到 `_KNOWN_DELIVERY_PLATFORMS` frozenset 和 `_deliver_result()` 平台映射
### 6. CLI 集成
1. **`hermes_cli/config.py`** — 将所有 `NEWPLAT_*` 变量添加到 `_EXTRA_ENV_KEYS`
2. **`hermes_cli/gateway.py`** — 在 `_PLATFORMS` 列表中添加条目,包含 key、label、emoji、token_var、setup_instructions 和 vars
3. **`hermes_cli/platforms.py`** — 添加带 label 和 default_toolset 的 `PlatformInfo` 条目(供 `skills_config``tools_config` TUI 使用)
4. **`hermes_cli/setup.py`** — 添加 `_setup_newplat()` 函数(可委托给 `gateway.py`)并将元组添加到消息平台列表
5. **`hermes_cli/status.py`** — 添加平台检测条目:`"NewPlat": ("NEWPLAT_TOKEN", "NEWPLAT_HOME_CHANNEL")`
6. **`hermes_cli/dump.py`** — 将 `"newplat": "NEWPLAT_TOKEN"` 添加到平台检测字典
### 7. 工具
1. **`tools/send_message_tool.py`** — 将 `"newplat": Platform.NEWPLAT` 添加到平台映射
2. **`tools/cronjob_tools.py`** — 将 `newplat` 添加到投递目标描述字符串
### 8. Toolset
1. **`toolsets.py`** — 添加带 `_HERMES_CORE_TOOLS``"hermes-newplat"` toolset 定义
2. **`toolsets.py`** — 将 `"hermes-newplat"` 添加到 `"hermes-gateway"` 的 includes 列表
### 9. 可选:平台提示
**`agent/prompt_builder.py`** — 如果你的平台有特定渲染限制(不支持 markdown、消息长度限制等),在 `_PLATFORM_HINTS` 字典中添加条目。这会将平台专属指导注入系统 prompt:
```python
_PLATFORM_HINTS = {
# ...
"newplat": (
"You are chatting via NewPlat. It supports markdown formatting "
"but has a 4000-character message limit."
),
}
```
并非所有平台都需要提示 — 仅在 agent 行为应有所不同时添加。
### 10. 测试
创建 `tests/gateway/test_newplat.py`,覆盖:
- 从配置构建适配器
- 消息事件构建
- 发送方法(mock 外部 API
- 平台专属功能(加密、路由等)
### 11. 文档
| 文件 | 需添加内容 |
|------|-------------|
| `website/docs/user-guide/messaging/newplat.md` | 完整的平台设置页面 |
| `website/docs/user-guide/messaging/index.md` | 平台对比表、架构图、toolset 表、安全章节、下一步链接 |
| `website/docs/reference/environment-variables.md` | 所有 NEWPLAT_* 环境变量 |
| `website/docs/reference/toolsets-reference.md` | hermes-newplat toolset |
| `website/docs/integrations/index.md` | 平台链接 |
| `website/sidebars.ts` | 文档页面的侧边栏条目 |
| `website/docs/developer-guide/architecture.md` | 适配器数量 + 列表 |
| `website/docs/developer-guide/gateway-internals.md` | 适配器文件列表 |
## 一致性审计
在将新平台 PR 标记为完成之前,对照已有平台进行一致性审计:
```bash
# 查找所有提及参考平台的 .py 文件
search_files "bluebubbles" output_mode="files_only" file_glob="*.py"
# 查找所有提及新平台的 .py 文件
search_files "newplat" output_mode="files_only" file_glob="*.py"
# 在第一个集合中但不在第二个集合中的文件是潜在的遗漏点
```
`.md``.ts` 文件重复上述操作。逐一排查每个遗漏点 — 是平台枚举(需要更新)还是平台专属引用(可跳过)?
## 常见模式
### 长轮询适配器
如果你的适配器使用长轮询(如 Telegram 或 Weixin),使用轮询循环任务:
```python
async def connect(self):
self._poll_task = asyncio.create_task(self._poll_loop())
self._mark_connected()
async def _poll_loop(self):
while self._running:
messages = await self._fetch_updates()
for msg in messages:
await self.handle_message(self._build_event(msg))
```
### 回调/Webhook 适配器
如果平台将消息推送到你的端点(如 WeCom 回调),运行 HTTP 服务器:
```python
async def connect(self):
self._app = web.Application()
self._app.router.add_post("/callback", self._handle_callback)
# ... 启动 aiohttp 服务器
self._mark_connected()
async def _handle_callback(self, request):
event = self._build_event(await request.text())
await self._message_queue.put(event)
return web.Response(text="success") # 立即确认
```
对于有严格响应截止时间的平台(例如 WeCom 的 5 秒限制),始终立即确认,稍后通过 API 主动投递 agent 的回复。Agent 会话运行 3–30 分钟 — 在回调响应窗口内内联回复是不可行的。
### Token 锁
如果适配器持有带唯一凭据的持久连接,添加作用域锁以防止两个配置文件使用相同凭据:
```python
from gateway.status import acquire_scoped_lock, release_scoped_lock
async def connect(self):
if not acquire_scoped_lock("newplat", self._token):
logger.error("Token already in use by another profile")
return False
# ... 连接
async def disconnect(self):
release_scoped_lock("newplat", self._token)
```
## 参考实现
| 适配器 | 模式 | 复杂度 | 适合参考的场景 |
|---------|---------|------------|-------------------|
| `bluebubbles.py` | REST + webhook | 中 | 简单 REST API 集成 |
| `weixin.py` | 长轮询 + CDN | 高 | 媒体处理、加密 |
| `wecom_callback.py` | 回调/webhook | 中 | HTTP 服务器、AES 加密、多应用 |
| `telegram.py` | 长轮询 + Bot API | 高 | 支持群组、线程的全功能适配器 |
@@ -0,0 +1,459 @@
---
sidebar_position: 5
title: "添加 Provider"
description: "如何向 Hermes Agent 添加新的推理 provider——认证、运行时解析、CLI 流程、适配器、测试与文档"
---
# 添加 Provider
Hermes 已经可以通过自定义 provider 路径与任何 OpenAI 兼容的端点通信。除非你需要为某个服务提供一流的用户体验,否则不要添加内置 provider:
- provider 专属的认证或 token 刷新
- 精选的模型目录
- setup / `hermes model` 菜单条目
- 用于 `provider:model` 语法的 provider 别名
- 需要适配器的非 OpenAI API 格式
如果该 provider 只是"另一个 OpenAI 兼容的 base URL 和 API key",一个命名的自定义 provider 可能就足够了。
## 心智模型
内置 provider 需要在几个层面保持一致:
1. `hermes_cli/auth.py` 决定如何查找凭据。
2. `hermes_cli/runtime_provider.py` 将其转换为运行时数据:
- `provider`
- `api_mode`
- `base_url`
- `api_key`
- `source`
3. `run_agent.py` 使用 `api_mode` 决定如何构建和发送请求。
4. `hermes_cli/models.py``hermes_cli/main.py` 使 provider 在 CLI 中可见。(`hermes_cli/setup.py` 自动委托给 `main.py`——无需在此处做任何修改。)
5. `agent/auxiliary_client.py``agent/model_metadata.py` 保持辅助任务和 token 预算正常运作。
核心抽象是 `api_mode`
- 大多数 provider 使用 `chat_completions`
- Codex 使用 `codex_responses`
- Anthropic 使用 `anthropic_messages`
- 新的非 OpenAI 协议通常意味着需要添加新的适配器和新的 `api_mode` 分支。
## 首先选择实现路径
### 路径 A——OpenAI 兼容 provider
当 provider 接受标准 chat-completions 风格的请求时使用此路径。
典型工作:
- 添加认证元数据
- 添加模型目录 / 别名
- 添加运行时解析
- 添加 CLI 菜单接线
- 添加辅助模型默认值
- 添加测试和用户文档
通常不需要新的适配器或新的 `api_mode`
### 路径 B——原生 provider
当 provider 的行为与 OpenAI chat completions 不同时使用此路径。
当前代码库中的示例:
- `codex_responses`
- `anthropic_messages`
此路径包含路径 A 的所有内容,另加:
- `agent/` 中的 provider 适配器
- `run_agent.py` 中用于请求构建、分发、用量提取、中断处理和响应规范化的分支
- 适配器测试
## 文件清单
### 每个内置 provider 都必须修改
1. `hermes_cli/auth.py`
2. `hermes_cli/models.py`
3. `hermes_cli/runtime_provider.py`
4. `hermes_cli/main.py`
5. `agent/auxiliary_client.py`
6. `agent/model_metadata.py`
7. 测试
8. `website/docs/` 下的用户文档
:::tip
`hermes_cli/setup.py` **无需**修改。setup 向导将 provider/model 选择委托给 `main.py` 中的 `select_provider_and_model()`——在那里添加的任何 provider 都会自动出现在 `hermes setup` 中。
:::
### 原生 / 非 OpenAI provider 额外需要
10. `agent/<provider>_adapter.py`
11. `run_agent.py`
12. 如果需要 provider SDK,则修改 `pyproject.toml`
## 快速路径:简单 API key provider
如果你的 provider 只是一个使用单个 API key 进行认证的 OpenAI 兼容端点,则无需修改 `auth.py``runtime_provider.py``main.py` 或下面完整清单中的任何其他文件。
你只需要:
1.`plugins/model-providers/<your-provider>/` 下创建一个插件目录,包含:
- `__init__.py`——在模块级别调用 `register_provider(profile)`
- `plugin.yaml`——清单文件(name、kind: model-provider、version、description
2. 就这些。Provider 插件在任何代码首次调用 `get_provider_profile()``list_providers()` 时自动加载——捆绑插件(本仓库)和位于 `$HERMES_HOME/plugins/model-providers/` 的用户插件都会被加载。
当你添加一个插件并调用 `register_provider()` 时,以下内容会自动接线:
1. `auth.py` 中的 `PROVIDER_REGISTRY` 条目(凭据解析、环境变量查找)
2. `api_mode` 设置为 `chat_completions`
3. `base_url` 从配置或声明的环境变量中获取
4. 按优先级顺序检查 `env_vars` 以获取 API key
5. 为该 provider 注册 `fallback_models` 列表
6. `--provider` CLI 标志接受该 provider id
7. `hermes model` 菜单包含该 provider
8. `hermes setup` 向导自动委托给 `main.py`
9. `provider:model` 别名语法正常工作
10. 运行时解析器返回正确的 `base_url``api_key`
11. `--provider <name>` CLI 标志接受该 provider id
12. 回退模型激活可以干净地切换到该 provider
位于 `$HERMES_HOME/plugins/model-providers/<name>/` 的用户插件会覆盖同名的捆绑插件(`register_provider()` 中后写者获胜)——因此第三方可以在不编辑本仓库的情况下对任何内置 profile 进行 monkey-patch 或替换。
参见 `plugins/model-providers/nvidia/``plugins/model-providers/gmi/` 作为模板,以及完整的 [Model Provider Plugin 指南](/developer-guide/model-provider-plugin),了解字段参考、hook 用法和端到端示例。
## 完整路径:OAuth 和复杂 provider
当你的 provider 需要以下任何内容时,使用下面的完整清单:
- OAuth 或 token 刷新(Nous Portal、Codex、Google Gemini、Qwen Portal、Copilot
- 需要新适配器的非 OpenAI API 格式(Anthropic Messages、Codex Responses
- 自定义端点检测或多区域探测(z.ai、Kimi)
- 精选的静态模型目录或实时 `/models` 获取
- 带有特定认证流程的 provider 专属 `hermes model` 菜单条目
## 第 1 步:选择一个规范的 provider id
选择一个 provider id 并在所有地方使用它。
代码库中的示例:
- `openai-codex`
- `kimi-coding`
- `minimax-cn`
该 id 应出现在:
- `hermes_cli/auth.py` 中的 `PROVIDER_REGISTRY`
- `hermes_cli/models.py` 中的 `_PROVIDER_LABELS`
- `hermes_cli/auth.py``hermes_cli/models.py` 中的 `_PROVIDER_ALIASES`
- `hermes_cli/main.py` 中的 CLI `--provider` 选项
- setup / 模型选择分支
- 辅助模型默认值
- 测试
如果这些文件之间的 id 不一致,provider 会感觉只接了一半线:认证可能正常,而 `/model`、setup 或运行时解析会静默地遗漏它。
## 第 2 步:在 `hermes_cli/auth.py` 中添加认证元数据
对于 API key provider,在 `PROVIDER_REGISTRY` 中添加一个 `ProviderConfig` 条目,包含:
- `id`
- `name`
- `auth_type="api_key"`
- `inference_base_url`
- `api_key_env_vars`
- 可选的 `base_url_env_var`
同时在 `_PROVIDER_ALIASES` 中添加别名。
使用现有 provider 作为模板:
- 简单 API key 路径:Z.AI、MiniMax
- 带端点检测的 API key 路径:Kimi、Z.AI
- 原生 token 解析:Anthropic
- OAuth / auth-store 路径:Nous、OpenAI Codex
需要在此回答的问题:
- Hermes 应该检查哪些环境变量,按什么优先级顺序?
- provider 是否需要 base URL 覆盖?
- 是否需要端点探测或 token 刷新?
- 当凭据缺失时,认证错误应该显示什么?
如果 provider 需要的不仅仅是"查找 API key",请添加专用的凭据解析器,而不是将逻辑塞进不相关的分支。
## 第 3 步:在 `hermes_cli/models.py` 中添加模型目录和别名
更新 provider 目录,使 provider 在菜单和 `provider:model` 语法中正常工作。
典型修改:
- `_PROVIDER_MODELS`
- `_PROVIDER_LABELS`
- `_PROVIDER_ALIASES`
- `list_available_providers()` 中的 provider 显示顺序
- 如果 provider 支持实时 `/models` 获取,则修改 `provider_model_ids()`
如果 provider 提供实时模型列表,优先使用它,并将 `_PROVIDER_MODELS` 保留为静态回退。
此文件也是使以下输入正常工作的关键:
```text
anthropic:claude-sonnet-4-6
kimi:model-name
```
如果此处缺少别名,provider 可能认证正常,但在 `/model` 解析中仍然失败。
## 第 4 步:在 `hermes_cli/runtime_provider.py` 中解析运行时数据
`resolve_runtime_provider()` 是 CLI、gateway(网关)、cron、ACP 和辅助客户端共用的路径。
添加一个分支,至少返回包含以下内容的字典:
```python
{
"provider": "your-provider",
"api_mode": "chat_completions", # or your native mode
"base_url": "https://...",
"api_key": "...",
"source": "env|portal|auth-store|explicit",
"requested_provider": requested_provider,
}
```
如果 provider 与 OpenAI 兼容,`api_mode` 通常应保持为 `chat_completions`
注意 API key 优先级。Hermes 已经包含避免将 OpenRouter key 泄露给无关端点的逻辑。新 provider 应同样明确地指定哪个 key 对应哪个 base URL。
## 第 5 步:在 `hermes_cli/main.py` 中接线 CLI
在交互式 `hermes model` 流程中出现之前,provider 是不可发现的。
`hermes_cli/main.py` 中更新以下内容:
- `provider_labels` 字典
- `select_provider_and_model()` 中的 `providers` 列表
- provider 分发(`if selected_provider == ...`
- `--provider` 参数选项
- 如果 provider 支持登录/登出流程,则更新相应选项
- 一个 `_model_flow_<provider>()` 函数,或者如果适用则复用 `_model_flow_api_key_provider()`
:::tip
`hermes_cli/setup.py` 无需修改——它调用 `main.py` 中的 `select_provider_and_model()`,因此你的新 provider 会自动出现在 `hermes model``hermes setup` 中。
:::
## 第 6 步:保持辅助调用正常工作
这里有两个文件需要关注:
### `agent/auxiliary_client.py`
如果这是一个直接 API key provider,在 `_API_KEY_PROVIDER_AUX_MODELS` 中添加一个廉价/快速的默认辅助模型。
辅助任务包括:
- 视觉摘要
- 网页提取摘要
- 上下文压缩摘要
- 会话搜索摘要
- 记忆刷新
如果 provider 没有合理的辅助默认值,辅助任务可能会严重回退,或意外使用昂贵的主模型。
### `agent/model_metadata.py`
为 provider 的模型添加上下文长度,以保持 token 预算、压缩阈值和限制的合理性。
## 第 7 步:如果 provider 是原生的,添加适配器和 `run_agent.py` 支持
如果 provider 不是普通的 chat completions,将 provider 专属逻辑隔离在 `agent/<provider>_adapter.py` 中。
保持 `run_agent.py` 专注于编排。它应该调用适配器辅助函数,而不是在整个文件中内联构建 provider 请求载荷。
原生 provider 通常需要在以下地方进行工作:
### 新适配器文件
典型职责:
- 构建 SDK / HTTP 客户端
- 解析 token
- 将 OpenAI 风格的对话消息转换为 provider 的请求格式
- 如有需要,转换工具 schema
- 将 provider 响应规范化为 `run_agent.py` 期望的格式
- 提取用量和 finish-reason 数据
### `run_agent.py`
搜索 `api_mode` 并审计每个切换点。至少验证:
- `__init__` 选择了新的 `api_mode`
- 客户端构建对该 provider 有效
- `_build_api_kwargs()` 知道如何格式化请求
- `_interruptible_api_call()` 分发到正确的客户端调用
- 中断 / 客户端重建路径正常工作
- 响应验证接受该 provider 的格式
- finish-reason 提取正确
- token 用量提取正确
- 回退模型激活可以干净地切换到新 provider
- 摘要生成和记忆刷新路径仍然正常工作
同时在 `run_agent.py` 中搜索 `self.client.`。任何假设标准 OpenAI 客户端存在的代码路径,在原生 provider 使用不同客户端对象或 `self.client = None` 时都可能中断。
### Prompt 缓存和 provider 专属请求字段
Prompt(提示词)缓存和 provider 专属的调节项很容易出现回归。
代码库中已有的示例:
- Anthropic 有原生的 prompt 缓存路径
- OpenRouter 获得 provider 路由字段
- 并非每个 provider 都应该接收每个请求端选项
添加原生 provider 时,仔细检查 Hermes 只向该 provider 发送它实际理解的字段。
## 第 8 步:测试
至少修改保护 provider 接线的测试。
常见位置:
- `tests/test_runtime_provider_resolution.py`
- `tests/test_cli_provider_resolution.py`
- `tests/test_cli_model_command.py`
- `tests/test_setup_model_selection.py`
- `tests/test_provider_parity.py`
- `tests/test_run_agent.py`
- 原生 provider 的 `tests/test_<provider>_adapter.py`
对于仅文档示例,确切的文件集可能不同。重点是覆盖:
- 认证解析
- CLI 菜单 / provider 选择
- 运行时 provider 解析
- agent 执行路径
- `provider:model` 解析
- 任何适配器专属的消息转换
使用禁用 xdist 的方式运行测试:
```bash
source venv/bin/activate
python -m pytest tests/test_runtime_provider_resolution.py tests/test_cli_provider_resolution.py tests/test_cli_model_command.py tests/test_setup_model_selection.py -n0 -q
```
对于更深层的修改,在推送前运行完整测试套件:
```bash
source venv/bin/activate
python -m pytest tests/ -n0 -q
```
## 第 9 步:实时验证
测试通过后,运行真实的冒烟测试。
```bash
source venv/bin/activate
python -m hermes_cli.main chat -q "Say hello" --provider your-provider --model your-model
```
如果你修改了菜单,也测试交互式流程:
```bash
source venv/bin/activate
python -m hermes_cli.main model
python -m hermes_cli.main setup
```
对于原生 provider,至少也验证一次工具调用,而不仅仅是纯文本响应。
## 第 10 步:更新用户文档
如果该 provider 打算作为一流选项发布,也更新用户文档:
- `website/docs/getting-started/quickstart.md`
- `website/docs/user-guide/configuration.md`
- `website/docs/reference/environment-variables.md`
开发者可以完美地接线 provider,但仍然让用户无法发现所需的环境变量或 setup 流程。
## OpenAI 兼容 provider 清单
如果 provider 是标准 chat completions,使用此清单。
- [ ]`hermes_cli/auth.py` 中添加 `ProviderConfig`
- [ ]`hermes_cli/auth.py``hermes_cli/models.py` 中添加别名
- [ ]`hermes_cli/models.py` 中添加模型目录
- [ ]`hermes_cli/runtime_provider.py` 中添加运行时分支
- [ ]`hermes_cli/main.py` 中添加 CLI 接线(setup.py 自动继承)
- [ ]`agent/auxiliary_client.py` 中添加辅助模型
- [ ]`agent/model_metadata.py` 中添加上下文长度
- [ ] 更新运行时 / CLI 测试
- [ ] 更新用户文档
## 原生 provider 清单
当 provider 需要新的协议路径时使用此清单。
- [ ] OpenAI 兼容清单中的所有内容
- [ ]`agent/<provider>_adapter.py` 中添加适配器
- [ ]`run_agent.py` 中支持新的 `api_mode`
- [ ] 中断 / 重建路径正常工作
- [ ] 用量和 finish-reason 提取正常工作
- [ ] 回退路径正常工作
- [ ] 添加适配器测试
- [ ] 实时冒烟测试通过
## 常见陷阱
### 1. 将 provider 添加到 auth 但未添加到模型解析
这会导致凭据解析正确,而 `/model``provider:model` 输入失败。
### 2. 忘记 `config["model"]` 可以是字符串或字典
大量 provider 选择代码必须对两种形式进行规范化。
### 3. 假设必须使用内置 provider
如果该服务只是 OpenAI 兼容的,自定义 provider 可能已经以更少的维护成本解决了用户问题。
### 4. 忘记辅助路径
主聊天路径可能正常工作,而摘要、记忆刷新或视觉辅助失败,因为辅助路由从未更新。
### 5. 原生 provider 分支隐藏在 `run_agent.py` 中
搜索 `api_mode``self.client.`。不要假设显而易见的请求路径是唯一的。
### 6. 将 OpenRouter 专属字段发送给其他 provider
provider 路由等字段只属于支持它们的 provider。
### 7. 更新了 `hermes model` 但未更新 `hermes setup`
两个流程都需要了解该 provider。
## 实现时的好搜索目标
如果你在寻找 provider 涉及的所有位置,搜索以下符号:
- `PROVIDER_REGISTRY`
- `_PROVIDER_ALIASES`
- `_PROVIDER_MODELS`
- `resolve_runtime_provider`
- `_model_flow_`
- `select_provider_and_model`
- `api_mode`
- `_API_KEY_PROVIDER_AUX_MODELS`
- `self.client.`
## 相关文档
- [Provider 运行时解析](./provider-runtime.md)
- [架构](./architecture.md)
- [贡献指南](./contributing.md)
@@ -0,0 +1,209 @@
---
sidebar_position: 2
title: "添加工具"
description: "如何向 Hermes Agent 添加新工具——schema、handler、注册与 toolset"
---
# 添加工具
在编写工具之前,先问自己:**这是否应该是一个 [skill](creating-skills.md)**
:::warning 仅限内置核心工具
本页面用于向仓库本身添加 **Hermes 内置工具**
如果你想要个人专用、项目本地或其他自定义工具,而不修改 Hermes 核心,请使用插件方式:
- [插件](/user-guide/features/plugins)
- [构建 Hermes 插件](/guides/build-a-hermes-plugin)
大多数自定义工具创建场景默认使用插件。只有当你明确希望在 `tools/``toolsets.py` 中发布新的内置工具时,才遵循本页面。
:::
以下情况应创建 **Skill**:该能力可以通过指令 + shell 命令 + 现有工具来实现(如 arXiv 搜索、git 工作流、Docker 管理、PDF 处理)。
以下情况应创建 **Tool**:需要与 API 密钥进行端到端集成、自定义处理逻辑、二进制数据处理或流式传输(如浏览器自动化、TTS、视觉分析)。
## 概述
添加一个工具涉及 **2 个文件**
1. **`tools/your_tool.py`** — handler、schema、check 函数、`registry.register()` 调用
2. **`toolsets.py`** — 将工具名称添加到 `_HERMES_CORE_TOOLS`(或特定 toolset
任何包含顶层 `registry.register()` 调用的 `tools/*.py` 文件都会在启动时被自动发现——无需手动维护导入列表。
## 第一步:创建内置工具文件
每个工具文件遵循相同的结构:
```python
# tools/weather_tool.py
"""Weather Tool -- look up current weather for a location."""
import json
import os
import logging
logger = logging.getLogger(__name__)
# --- Availability check ---
def check_weather_requirements() -> bool:
"""Return True if the tool's dependencies are available."""
return bool(os.getenv("WEATHER_API_KEY"))
# --- Handler ---
def weather_tool(location: str, units: str = "metric") -> str:
"""Fetch weather for a location. Returns JSON string."""
api_key = os.getenv("WEATHER_API_KEY")
if not api_key:
return json.dumps({"error": "WEATHER_API_KEY not configured"})
try:
# ... call weather API ...
return json.dumps({"location": location, "temp": 22, "units": units})
except Exception as e:
return json.dumps({"error": str(e)})
# --- Schema ---
WEATHER_SCHEMA = {
"name": "weather",
"description": "Get current weather for a location.",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "City name or coordinates (e.g. 'London' or '51.5,-0.1')"
},
"units": {
"type": "string",
"enum": ["metric", "imperial"],
"description": "Temperature units (default: metric)",
"default": "metric"
}
},
"required": ["location"]
}
}
# --- Registration ---
from tools.registry import registry
registry.register(
name="weather",
toolset="weather",
schema=WEATHER_SCHEMA,
handler=lambda args, **kw: weather_tool(
location=args.get("location", ""),
units=args.get("units", "metric")),
check_fn=check_weather_requirements,
requires_env=["WEATHER_API_KEY"],
)
```
### 关键规则
:::danger 重要
- Handler **必须**返回 JSON 字符串(通过 `json.dumps()`),不得返回原始 dict
- 错误**必须**以 `{"error": "message"}` 形式返回,不得抛出异常
- `check_fn` 在构建工具定义时被调用——若返回 `False`,该工具将被静默排除
- `handler` 接收 `(args: dict, **kwargs)`,其中 `args` 是 LLM 的工具调用参数
:::
## 第二步:将内置工具添加到 Toolset
`toolsets.py` 中添加工具名称:
```python
# If it should be available on all platforms (CLI + messaging):
_HERMES_CORE_TOOLS = [
...
"weather", # <-- add here
]
# Or create a new standalone toolset:
"weather": {
"description": "Weather lookup tools",
"tools": ["weather"],
"includes": []
},
```
## ~~第三步:添加发现导入~~(不再需要)
包含顶层 `registry.register()` 调用的工具模块会由 `tools/registry.py` 中的 `discover_builtin_tools()` 自动发现。无需手动维护导入列表——只需在 `tools/` 中创建文件,启动时即可自动加载。
## 异步 Handler
如果你的 handler 需要异步代码,使用 `is_async=True` 标记:
```python
async def weather_tool_async(location: str) -> str:
async with aiohttp.ClientSession() as session:
...
return json.dumps(result)
registry.register(
name="weather",
toolset="weather",
schema=WEATHER_SCHEMA,
handler=lambda args, **kw: weather_tool_async(args.get("location", "")),
check_fn=check_weather_requirements,
is_async=True, # registry calls _run_async() automatically
)
```
registry 会透明地处理异步桥接——你无需自己调用 `asyncio.run()`
## 需要 task_id 的 Handler
管理每个会话状态的工具通过 `**kwargs` 接收 `task_id`
```python
def _handle_weather(args, **kw):
task_id = kw.get("task_id")
return weather_tool(args.get("location", ""), task_id=task_id)
registry.register(
name="weather",
...
handler=_handle_weather,
)
```
## Agent 循环拦截工具
某些工具(`todo``memory``session_search``delegate_task`)需要访问每个会话的 agent 状态。这些工具在到达 registry 之前会被 `run_agent.py` 拦截。registry 仍然保存它们的 schema,但如果绕过拦截,`dispatch()` 会返回一个回退错误。
## 可选:Setup Wizard 集成
如果你的工具需要 API 密钥,将其添加到 `hermes_cli/config.py`
```python
OPTIONAL_ENV_VARS = {
...
"WEATHER_API_KEY": {
"description": "Weather API key for weather lookup",
"prompt": "Weather API key",
"url": "https://weatherapi.com/",
"tools": ["weather"],
"password": True,
},
}
```
## 检查清单
- [ ] 已创建包含 handler、schema、check 函数和注册调用的工具文件
- [ ] 已在 `toolsets.py` 中添加到适当的 toolset
- [ ] 已确认该工具确实应为内置/核心工具而非插件
- [ ] Handler 返回 JSON 字符串,错误以 `{"error": "..."}` 形式返回
- [ ] 可选:已将 API 密钥添加到 `hermes_cli/config.py``OPTIONAL_ENV_VARS`
- [ ] 可选:已添加到 `toolset_distributions.py` 以支持批量处理
- [ ] 已通过 `hermes chat -q "Use the weather tool for London"` 测试
@@ -0,0 +1,239 @@
---
sidebar_position: 3
title: "Agent Loop 内部机制"
description: "AIAgent 执行流程、API 模式、工具、回调及回退行为的详细说明"
---
# Agent Loop 内部机制
核心编排引擎是 `run_agent.py` 中的 `AIAgent` 类——这是一个大型文件(15k+ 行),负责处理从 prompt(提示词)组装到工具分发再到 provider 故障转移的所有逻辑。
## 核心职责
`AIAgent` 负责:
- 通过 `prompt_builder.py` 组装有效的系统 prompt 和工具 schema
- 选择正确的 provider/API 模式(`chat_completions``codex_responses``anthropic_messages`
- 发起支持取消操作的可中断模型调用
- 执行工具调用(顺序执行或通过线程池并发执行)
- 以 OpenAI 消息格式维护对话历史
- 处理压缩、重试和回退模型切换
- 跨父 agent 和子 agent 追踪迭代预算
- 在上下文丢失前将持久化内存刷写到磁盘
## 两个入口点
```python
# 简单接口——返回最终响应字符串
response = agent.chat("Fix the bug in main.py")
# 完整接口——返回包含消息、元数据、用量统计的 dict
result = agent.run_conversation(
user_message="Fix the bug in main.py",
system_message=None, # 省略时自动构建
conversation_history=None, # 省略时自动从 session 加载
task_id="task_abc123"
)
```
`chat()` 是对 `run_conversation()` 的轻量封装,从结果 dict 中提取 `final_response` 字段。
## API 模式
Hermes 支持三种 API 执行模式,通过 provider 选择、显式参数和 base URL 启发式规则来确定:
| API 模式 | 用途 | 客户端类型 |
|----------|------|-----------|
| `chat_completions` | 兼容 OpenAI 的端点(OpenRouter、自定义及大多数 provider | `openai.OpenAI` |
| `codex_responses` | OpenAI Codex / Responses API | `openai.OpenAI`(使用 Responses 格式) |
| `anthropic_messages` | 原生 Anthropic Messages API | 通过适配器使用 `anthropic.Anthropic` |
模式决定了消息的格式化方式、工具调用的结构、响应的解析方式,以及缓存/流式传输的工作方式。三种模式在 API 调用前后均收敛到相同的内部消息格式(OpenAI 风格的 `role`/`content`/`tool_calls` dict)。
**模式解析顺序:**
1. 显式 `api_mode` 构造函数参数(最高优先级)
2. Provider 特定检测(例如 `anthropic` provider → `anthropic_messages`
3. Base URL 启发式规则(例如 `api.anthropic.com``anthropic_messages`
4. 默认:`chat_completions`
## 单轮生命周期
agent loop 的每次迭代按以下顺序执行:
```text
run_conversation()
1. 若未提供则生成 task_id
2. 将用户消息追加到对话历史
3. 构建或复用已缓存的系统 promptprompt_builder.py
4. 检查是否需要预检压缩(上下文超过 50%)
5. 从对话历史构建 API 消息
- chat_completions:直接使用 OpenAI 格式
- codex_responses:转换为 Responses API 输入项
- anthropic_messages:通过 anthropic_adapter.py 转换
6. 注入临时 prompt 层(预算警告、上下文压力提示)
7. 若使用 Anthropic,应用 prompt 缓存标记
8. 发起可中断的 API 调用(_interruptible_api_call
9. 解析响应:
- 若有 tool_calls:执行工具,追加结果,回到步骤 5
- 若为文本响应:持久化 session,按需刷写内存,返回
```
### 消息格式
所有消息在内部均使用兼容 OpenAI 的格式:
```python
{"role": "system", "content": "..."}
{"role": "user", "content": "..."}
{"role": "assistant", "content": "...", "tool_calls": [...]}
{"role": "tool", "tool_call_id": "...", "content": "..."}
```
推理内容(来自支持扩展思考的模型)存储在 `assistant_msg["reasoning"]` 中,并可选择通过 `reasoning_callback` 展示。
### 消息交替规则
agent loop 强制执行严格的消息角色交替规则:
- 系统消息之后:`User → Assistant → User → Assistant → ...`
- 工具调用期间:`Assistant(含 tool_calls)→ Tool → Tool → ... → Assistant`
- **不允许**连续出现两条 assistant 消息
- **不允许**连续出现两条 user 消息
- **只有** `tool` 角色可以连续出现(并行工具结果)
Provider 会验证这些序列,并拒绝格式错误的历史记录。
## 可中断的 API 调用
API 请求被封装在 `_interruptible_api_call()` 中,该方法在后台线程中执行实际的 HTTP 调用,同时监听中断事件:
```text
┌────────────────────────────────────────────────────┐
│ 主线程 API 线程 │
│ │
│ 等待: HTTP POST │
│ - 响应就绪 ───▶ 发送至 provider │
│ - 中断事件 │
│ - 超时 │
└────────────────────────────────────────────────────┘
```
当发生中断(用户发送新消息、`/stop` 命令或信号)时:
- API 线程被放弃(响应被丢弃)
- agent 可以处理新输入或干净地关闭
- 不会将部分响应注入对话历史
## 工具执行
### 顺序执行与并发执行
当模型返回工具调用时:
- **单个工具调用** → 直接在主线程中执行
- **多个工具调用** → 通过 `ThreadPoolExecutor` 并发执行
- 例外:标记为交互式的工具(如 `clarify`)强制顺序执行
- 无论完成顺序如何,结果均按原始工具调用顺序重新插入
### 执行流程
```text
for each tool_call in response.tool_calls:
1. 从 tools/registry.py 解析处理器
2. 触发 pre_tool_call 插件 hook
3. 检查是否为危险命令(tools/approval.py
- 若危险:调用 approval_callback,等待用户确认
4. 使用参数 + task_id 执行处理器
5. 触发 post_tool_call 插件 hook
6. 将 {"role": "tool", "content": result} 追加到历史
```
### Agent 级工具
部分工具在到达 `handle_function_call()` 之前,由 `run_agent.py` *提前*拦截:
| 工具 | 拦截原因 |
|------|---------|
| `todo` | 读写 agent 本地任务状态 |
| `memory` | 向持久化内存文件写入内容(有字符限制) |
| `session_search` | 通过 agent 的 session DB 查询 session 历史 |
| `delegate_task` | 以隔离上下文生成子 agent |
这些工具直接修改 agent 状态,并返回合成的工具结果,不经过注册表。
## 回调接口
`AIAgent` 支持平台特定的回调,用于在 CLI、gateway 和 ACP 集成中实现实时进度展示:
| 回调 | 触发时机 | 使用方 |
|------|---------|--------|
| `tool_progress_callback` | 每次工具执行前后 | CLI spinner、gateway 进度消息 |
| `thinking_callback` | 模型开始/停止思考时 | CLI "thinking..." 指示器 |
| `reasoning_callback` | 模型返回推理内容时 | CLI 推理展示、gateway 推理块 |
| `clarify_callback` | 调用 `clarify` 工具时 | CLI 输入提示、gateway 交互消息 |
| `step_callback` | 每次完整 agent 轮次结束后 | Gateway 步骤追踪、ACP 进度 |
| `stream_delta_callback` | 每个流式 token(启用时) | CLI 流式展示 |
| `tool_gen_callback` | 从流中解析出工具调用时 | CLI spinner 中的工具预览 |
| `status_callback` | 状态变更时(思考、执行等) | ACP 状态更新 |
## 预算与回退行为
### 迭代预算
agent 通过 `IterationBudget` 追踪迭代次数:
- 默认:90 次迭代(可通过 `agent.max_turns` 配置)
- 每个 agent 拥有独立预算。子 agent 获得独立预算,上限为 `delegation.max_iterations`(默认 50)——父 agent 与子 agent 的总迭代次数可超过父 agent 的上限
- 达到 100% 时,agent 停止并返回已完成工作的摘要
### 回退模型
当主模型失败时(429 限流、5xx 服务器错误、401/403 鉴权错误):
1. 检查配置中的 `fallback_providers` 列表
2. 按顺序尝试每个回退 provider
3. 成功后,使用新 provider 继续对话
4. 遇到 401/403 时,在故障转移前尝试刷新凭据
回退系统也独立覆盖辅助任务——视觉、压缩和网页提取各自拥有独立的回退链,可通过 `auxiliary.*` 配置节进行配置。
## 压缩与持久化
### 压缩触发时机
- **预检**(API 调用前):对话超过模型上下文窗口的 50%
- **Gateway 自动压缩**:对话超过 85%(更激进,在轮次之间运行)
### 压缩过程
1. 首先将内存刷写到磁盘(防止数据丢失)
2. 将中间对话轮次摘要为紧凑的摘要内容
3. 保留最后 N 条消息完整不变(`compression.protect_last_n`,默认:20
4. 工具调用/结果消息对保持完整(不拆分)
5. 生成新的 session 血缘 ID(压缩会创建一个"子" session
### Session 持久化
每轮结束后:
- 消息保存到 session 存储(通过 `hermes_state.py` 使用 SQLite
- 内存变更刷写到 `MEMORY.md` / `USER.md`
- 可通过 `/resume``hermes chat --resume` 恢复 session
## 关键源文件
| 文件 | 用途 |
|------|------|
| `run_agent.py` | AIAgent 类——完整的 agent loop |
| `agent/prompt_builder.py` | 从内存、技能、上下文文件和个性组装系统 prompt |
| `agent/context_engine.py` | ContextEngine ABC——可插拔的上下文管理 |
| `agent/context_compressor.py` | 默认引擎——有损摘要算法 |
| `agent/prompt_caching.py` | Anthropic prompt 缓存标记和缓存指标 |
| `agent/auxiliary_client.py` | 用于辅助任务的辅助 LLM 客户端(视觉、摘要) |
| `model_tools.py` | 工具 schema 集合,`handle_function_call()` 分发 |
## 相关文档
- [Provider 运行时解析](./provider-runtime.md)
- [Prompt 组装](./prompt-assembly.md)
- [上下文压缩与 Prompt 缓存](./context-compression-and-caching.md)
- [工具运行时](./tools-runtime.md)
- [架构概览](./architecture.md)
@@ -0,0 +1,277 @@
---
sidebar_position: 1
title: "架构"
description: "Hermes Agent 内部结构——主要子系统、执行路径、数据流及延伸阅读指引"
---
# 架构
本页是 Hermes Agent 内部结构的顶层导图。用它在代码库中定位自己,然后深入各子系统专项文档了解实现细节。
## 系统概览
```text
┌─────────────────────────────────────────────────────────────────────┐
│ Entry Points │
│ │
│ CLI (cli.py) Gateway (gateway/run.py) ACP (acp_adapter/) │
│ Batch Runner API Server Python Library │
└──────────┬──────────────┬───────────────────────┬───────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ AIAgent (run_agent.py) │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Prompt │ │ Provider │ │ Tool │ │
│ │ Builder │ │ Resolution │ │ Dispatch │ │
│ │ (prompt_ │ │ (runtime_ │ │ (model_ │ │
│ │ builder.py) │ │ provider.py)│ │ tools.py) │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ ┌──────┴───────┐ ┌──────┴───────┐ ┌──────┴───────┐ │
│ │ Compression │ │ 3 API Modes │ │ Tool Registry│ │
│ │ & Caching │ │ chat_compl. │ │ (registry.py)│ │
│ │ │ │ codex_resp. │ │ 70+ tools │ │
│ │ │ │ anthropic │ │ 28 toolsets │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────┴─────────────────┴─────────────────┴───────────────────────┘
│ │
▼ ▼
┌───────────────────┐ ┌──────────────────────┐
│ Session Storage │ │ Tool Backends │
│ (SQLite + FTS5) │ │ Terminal (7 backends) │
│ hermes_state.py │ │ Browser (5 backends) │
│ gateway/session.py│ │ Web (4 backends) │
└───────────────────┘ │ MCP (dynamic) │
│ File, Vision, etc. │
└──────────────────────┘
```
## 目录结构
```text
hermes-agent/
├── run_agent.py # AIAgent — 核心对话循环(大文件)
├── cli.py # HermesCLI — 交互式终端 UI(大文件)
├── model_tools.py # 工具发现、schema 收集、分发
├── toolsets.py # 工具分组与平台预设
├── hermes_state.py # 带 FTS5 的 SQLite 会话/状态数据库
├── hermes_constants.py # HERMES_HOME、感知 profile 的路径
├── batch_runner.py # 批量轨迹生成
├── agent/ # Agent 内部模块
│ ├── prompt_builder.py # 系统 prompt 组装
│ ├── context_engine.py # ContextEngine ABC(可插拔)
│ ├── context_compressor.py # 默认引擎——有损摘要压缩
│ ├── prompt_caching.py # Anthropic prompt 缓存
│ ├── auxiliary_client.py # 辅助 LLM,用于旁路任务(视觉、摘要)
│ ├── model_metadata.py # 模型上下文长度、token 估算
│ ├── models_dev.py # models.dev 注册表集成
│ ├── anthropic_adapter.py # Anthropic Messages API 格式转换
│ ├── display.py # KawaiiSpinner、工具预览格式化
│ ├── skill_commands.py # Skill 斜杠命令
│ ├── memory_manager.py # 记忆管理器编排
│ ├── memory_provider.py # 记忆提供者 ABC
│ └── trajectory.py # 轨迹保存辅助函数
├── hermes_cli/ # CLI 子命令与设置
│ ├── main.py # 入口点——所有 `hermes` 子命令(大文件)
│ ├── config.py # DEFAULT_CONFIG、OPTIONAL_ENV_VARS、迁移
│ ├── commands.py # COMMAND_REGISTRY——斜杠命令中央定义
│ ├── auth.py # PROVIDER_REGISTRY、凭据解析
│ ├── runtime_provider.py # Provider → api_mode + 凭据
│ ├── models.py # 模型目录、provider 模型列表
│ ├── model_switch.py # /model 命令逻辑(CLI + gateway 共用)
│ ├── setup.py # 交互式设置向导(大文件)
│ ├── skin_engine.py # CLI 主题引擎
│ ├── skills_config.py # hermes skills——按平台启用/禁用
│ ├── skills_hub.py # /skills 斜杠命令
│ ├── tools_config.py # hermes tools——按平台启用/禁用
│ ├── plugins.py # PluginManager——发现、加载、hook
│ ├── callbacks.py # 终端回调(clarify、sudo、approval
│ └── gateway.py # hermes gateway 启动/停止
├── tools/ # 工具实现(每个工具一个文件)
│ ├── registry.py # 中央工具注册表
│ ├── approval.py # 危险命令检测
│ ├── terminal_tool.py # 终端编排
│ ├── process_registry.py # 后台进程管理
│ ├── file_tools.py # read_file、write_file、patch、search_files
│ ├── web_tools.py # web_search、web_extract
│ ├── browser_tool.py # 10 个浏览器自动化工具
│ ├── code_execution_tool.py # execute_code 沙箱
│ ├── delegate_tool.py # 子 agent 委托
│ ├── mcp_tool.py # MCP 客户端(大文件)
│ ├── credential_files.py # 基于文件的凭据透传
│ ├── env_passthrough.py # 沙箱环境变量透传
│ ├── ansi_strip.py # ANSI 转义字符剥离
│ └── environments/ # 终端后端(local、docker、ssh、modal、daytona、singularity
├── gateway/ # 消息平台 gateway
│ ├── run.py # GatewayRunner——消息分发(大文件)
│ ├── session.py # SessionStore——对话持久化
│ ├── delivery.py # 出站消息投递
│ ├── pairing.py # DM 配对授权
│ ├── hooks.py # Hook 发现与生命周期事件
│ ├── mirror.py # 跨会话消息镜像
│ ├── status.py # Token 锁、profile 范围的进程追踪
│ ├── builtin_hooks/ # 始终注册的 hook 扩展点(当前无内置)
│ └── platforms/ # 20 个适配器:telegram、discord、slack、whatsapp、
│ # signal、matrix、mattermost、email、sms、
│ # dingtalk、feishu、wecom、wecom_callback、weixin、
│ # bluebubbles、qqbot、homeassistant、webhook、api_server、
│ # yuanbao
├── acp_adapter/ # ACP 服务器(VS Code / Zed / JetBrains
├── cron/ # 调度器(jobs.py、scheduler.py
├── plugins/memory/ # 记忆提供者插件
├── plugins/context_engine/ # 上下文引擎插件
├── skills/ # 内置 skill(始终可用)
├── optional-skills/ # 官方可选 skill(需显式安装)
├── website/ # Docusaurus 文档站点
└── tests/ # Pytest 测试套件(3,000+ 个测试)
```
## 数据流
### CLI 会话
```text
用户输入 → HermesCLI.process_input()
→ AIAgent.run_conversation()
→ prompt_builder.build_system_prompt()
→ runtime_provider.resolve_runtime_provider()
→ API 调用(chat_completions / codex_responses / anthropic_messages
→ tool_calls? → model_tools.handle_function_call() → 循环
→ 最终响应 → 显示 → 保存至 SessionDB
```
### Gateway 消息
```text
平台事件 → Adapter.on_message() → MessageEvent
→ GatewayRunner._handle_message()
→ 授权用户
→ 解析会话 key
→ 创建带会话历史的 AIAgent
→ AIAgent.run_conversation()
→ 通过适配器回传响应
```
### Cron 任务
```text
调度器触发 → 从 jobs.json 加载到期任务
→ 创建全新 AIAgent(无历史)
→ 将附加的 skill 注入为上下文
→ 运行任务 prompt
→ 向目标平台投递响应
→ 更新任务状态与 next_run
```
## 推荐阅读顺序
如果你是第一次接触代码库:
1. **本页** — 整体定位
2. **[Agent 循环内部机制](./agent-loop.md)** — AIAgent 的工作原理
3. **[Prompt 组装](./prompt-assembly.md)** — 系统 prompt 的构建过程
4. **[Provider 运行时解析](./provider-runtime.md)** — provider 的选择方式
5. **[添加 Provider](./adding-providers.md)** — 新增 provider 的实践指南
6. **[工具运行时](./tools-runtime.md)** — 工具注册表、分发、环境
7. **[会话存储](./session-storage.md)** — SQLite schema、FTS5、会话血缘
8. **[Gateway 内部机制](./gateway-internals.md)** — 消息平台 gateway
9. **[上下文压缩与 Prompt 缓存](./context-compression-and-caching.md)** — 压缩与缓存
10. **[ACP 内部机制](./acp-internals.md)** — IDE 集成
## 主要子系统
### Agent 循环
同步编排引擎(`run_agent.py` 中的 `AIAgent`)。负责 provider 选择、prompt 构建、工具执行、重试、回退、回调、压缩和持久化。支持三种 API 模式以适配不同 provider 后端。
→ [Agent 循环内部机制](./agent-loop.md)
### Prompt 系统
在对话生命周期中构建和维护 prompt:
- **`prompt_builder.py`** — 从以下来源组装系统 prompt:个性(SOUL.md)、记忆(MEMORY.md、USER.md)、skill、上下文文件(AGENTS.md、.hermes.md)、工具使用指引以及模型专项指令
- **`prompt_caching.py`** — 为前缀缓存应用 Anthropic 缓存断点
- **`context_compressor.py`** — 当上下文超出阈值时对中间对话轮次进行摘要
→ [Prompt 组装](./prompt-assembly.md)[上下文压缩与 Prompt 缓存](./context-compression-and-caching.md)
### Provider 解析
CLI、gateway、cron、ACP 及辅助调用共用的运行时解析器。将 `(provider, model)` 元组映射为 `(api_mode, api_key, base_url)`。支持 18+ 个 provider、OAuth 流程、凭据池和别名解析。
→ [Provider 运行时解析](./provider-runtime.md)
### 工具系统
中央工具注册表(`tools/registry.py`),包含约 28 个 toolset 中的 70+ 个已注册工具。每个工具文件在导入时自行注册。注册表负责 schema 收集、分发、可用性检查和错误包装。终端工具支持 6 种后端(local、Docker、SSH、Daytona、Modal、Singularity)。
→ [工具运行时](./tools-runtime.md)
### 会话持久化
基于 SQLite 的会话存储,带 FTS5 全文检索。会话具有血缘追踪(跨压缩的父/子关系)、按平台隔离,以及带竞争处理的原子写入。
→ [会话存储](./session-storage.md)
### 消息 Gateway
长驻进程,包含 20 个平台适配器、统一会话路由、用户授权(白名单 + DM 配对)、斜杠命令分发、hook 系统、cron 触发和后台维护。
→ [Gateway 内部机制](./gateway-internals.md)
### 插件系统
三种发现来源:`~/.hermes/plugins/`(用户级)、`.hermes/plugins/`(项目级)和 pip entry point。插件通过上下文 API 注册工具、hook 和 CLI 命令。存在两种专用插件类型:记忆提供者(`plugins/memory/`)和上下文引擎(`plugins/context_engine/`)。两者均为单选——每种同时只能激活一个,通过 `hermes plugins``config.yaml` 配置。
→ [插件指南](/guides/build-a-hermes-plugin)[记忆提供者插件](./memory-provider-plugin.md)
### Cron
一等公民的 agent 任务(非 shell 任务)。任务以 JSON 存储,支持多种调度格式,可附加 skill 和脚本,并可向任意平台投递。
→ [Cron 内部机制](./cron-internals.md)
### ACP 集成
通过 stdio/JSON-RPC 将 Hermes 作为编辑器原生 agent 暴露给 VS Code、Zed 和 JetBrains。
→ [ACP 内部机制](./acp-internals.md)
### 轨迹
从 agent 会话生成 ShareGPT 格式的轨迹,用于训练数据生成。
→ [轨迹与训练格式](./trajectory-format.md)
## 设计原则
| 原则 | 实践含义 |
|------|---------|
| **Prompt 稳定性** | 系统 prompt 在对话中途不会改变。除用户显式操作(`/model`)外,不进行破坏缓存的变更。 |
| **可观测执行** | 每次工具调用均通过回调对用户可见。CLI(spinner)和 gateway(聊天消息)中均有进度更新。 |
| **可中断** | API 调用和工具执行可被用户输入或信号在执行中途取消。 |
| **平台无关的核心** | 单一 AIAgent 类同时服务于 CLI、gateway、ACP、批处理和 API 服务器。平台差异存在于入口点,而非 agent 内部。 |
| **松耦合** | 可选子系统(MCP、插件、记忆提供者、RL 环境)使用注册表模式和 check_fn 门控,而非硬依赖。 |
| **Profile 隔离** | 每个 profile`hermes -p <name>`)拥有独立的 HERMES_HOME、配置、记忆、会话和 gateway PID。多个 profile 可并发运行。 |
## 文件依赖链
```text
tools/registry.py (无依赖——被所有工具文件导入)
tools/*.py (每个文件在导入时调用 registry.register()
model_tools.py (导入 tools/registry 并触发工具发现)
run_agent.py, cli.py, batch_runner.py, environments/
```
这条依赖链意味着工具注册发生在导入时,早于任何 agent 实例的创建。任何在顶层调用 `registry.register()``tools/*.py` 文件都会被自动发现——无需手动维护导入列表。
@@ -0,0 +1,160 @@
# Browser CDP Supervisor — 设计文档
**状态:** 已发布(PR 14540
**最后更新:** 2026-04-23
**作者:** @teknium1
## 问题
原生 JS 对话框(`alert`/`confirm`/`prompt`/`beforeunload`)和 iframe 是我们浏览器工具中最大的两个缺口:
1. **对话框会阻塞 JS 线程。** 页面上的任何操作都会挂起,直到对话框被处理。在此工作之前,agent 无法感知对话框是否已打开——后续的工具调用会挂起或抛出不透明的错误。
2. **iframe 不可见。** Agent 可以在 DOM 快照中看到 iframe 节点,但无法在其中点击、输入或执行 eval——尤其是运行在独立 Chromium 进程中的跨域(OOPIFiframe。
[PR #12550](https://github.com/NousResearch/hermes-agent/pull/12550) 提出了一个无状态的 `browser_dialog` 包装器。该方案无法解决检测问题——它只是在 agent 已经(通过症状)知道对话框已打开时,提供了一个更简洁的 CDP 调用。已作为被取代方案关闭。
## 后端能力矩阵(2026-04-23 实测验证)
使用一次性探测脚本,针对一个在主框架和同源 srcdoc iframe 中触发 alert 的 data-URL 页面,以及一个跨域 `https://example.com` iframe 进行测试:
| 后端 | 对话框检测 | 对话框响应 | 框架树 | OOPIF `Runtime.evaluate`(通过 `browser_cdp(frame_id=...)` |
|---|---|---|---|---|
| 本地 Chrome`--remote-debugging-port`/ `/browser connect` | ✓ | ✓ 完整流程 | ✓ | ✓ |
| Browserbase | ✓(通过 bridge | ✓ 完整流程(通过 bridge) | ✓ | ✓(`document.title = "Example Domain"` 已在真实跨域 iframe 上验证) |
| Camofox | ✗ 无 CDP(仅 REST | ✗ | 通过 DOM 快照部分支持 | ✗ |
**Browserbase 响应的工作原理。** Browserbase 的 CDP 代理在内部使用 Playwright,并在约 10ms 内自动关闭原生对话框,因此 `Page.handleJavaScriptDialog` 无法跟上。为解决此问题,supervisor 通过 `Page.addScriptToEvaluateOnNewDocument` 注入一个 bridge 脚本,将 `window.alert`/`confirm`/`prompt` 覆盖为向魔法主机(`hermes-dialog-bridge.invalid`)发起的同步 XHR。`Fetch.enable` 在这些 XHR 触达网络之前将其拦截——对话框变成 supervisor 捕获的 `Fetch.requestPaused` 事件,`respond_to_dialog` 通过 `Fetch.fulfillRequest` 以 JSON 响应体完成请求,注入的脚本对其进行解码。
最终效果:从页面角度看,`prompt()` 仍然返回 agent 提供的字符串。从 agent 角度看,无论哪种方式,都是同一套 `browser_dialog(action=...)` API。已针对真实 Browserbase 会话进行端到端测试——4/4alert/prompt/confirm-accept/confirm-dismiss)全部通过,包括值回传到页面 JS 的验证。
Camofox 在本 PR 中暂不支持;计划在 `jo-inc/camofox-browser` 提交上游 issue,请求添加对话框轮询端点。
## 架构
### CDPSupervisor
每个 Hermes `task_id` 对应一个在后台守护线程中运行的 `asyncio.Task`。持有一个到后端 CDP 端点的持久 WebSocket 连接。维护:
- **对话框队列** — `List[PendingDialog]`,包含 `{id, type, message, default_prompt, session_id, opened_at}`
- **框架树** — `Dict[frame_id, FrameInfo]`,包含父子关系、URL、origin,以及是否为跨域子会话
- **会话映射** — `Dict[session_id, SessionInfo]`,供交互工具将操作路由到正确的已附加会话以执行 OOPIF 操作
- **近期控制台错误** — 最近 50 条的环形缓冲区(用于 PR 2 诊断)
附加时订阅:
- `Page.enable``javascriptDialogOpening``frameAttached``frameNavigated``frameDetached`
- `Runtime.enable``executionContextCreated``consoleAPICalled``exceptionThrown`
- `Target.setAutoAttach {autoAttach: true, flatten: true}` — 暴露子 OOPIF targetsupervisor 在每个上启用 `Page`+`Runtime`
通过快照锁实现线程安全的状态访问;工具处理器(同步)读取冻结快照,无需 await。
### 生命周期
- **启动:** `SupervisorRegistry.get_or_start(task_id, cdp_url)` — 由 `browser_navigate`、Browserbase 会话创建、`/browser connect` 调用。幂等。
- **停止:** 会话拆除或 `/browser disconnect`。取消 asyncio task,关闭 WebSocket,丢弃状态。
- **重新绑定:** 若 CDP URL 变更(用户重新连接到新的 Chrome),停止旧 supervisor 并重新启动——绝不跨端点复用状态。
### 对话框策略
通过 `config.yaml` 中的 `browser.dialog_policy` 配置:
- **`must_respond`**(默认)— 捕获,在 `browser_snapshot` 中呈现,等待显式的 `browser_dialog(action=...)` 调用。在 300s 安全超时后若无响应,则自动关闭并记录日志。防止有缺陷的 agent 永久挂起。
- `auto_dismiss` — 记录并立即关闭;agent 事后通过 `browser_snapshot` 内的 `browser_state` 查看。
- `auto_accept` — 记录并接受(适用于用户希望干净导航离开时的 `beforeunload`)。
策略按 task 配置;v1 不支持按对话框覆盖。
## Agent 接口(PR 1
### 一个新工具
```
browser_dialog(action, prompt_text=None, dialog_id=None)
```
- `action="accept"` / `"dismiss"` → 响应指定的或唯一待处理的对话框(必填)
- `prompt_text=...` → 向 `prompt()` 对话框提供的文本
- `dialog_id=...` → 当多个对话框排队时用于消歧(罕见)
该工具仅用于响应。Agent 在调用前从 `browser_snapshot` 输出中读取待处理对话框。
### `browser_snapshot` 扩展
当 supervisor 已附加时,在现有快照输出中新增三个可选字段:
```json
{
"pending_dialogs": [
{"id": "d-1", "type": "alert", "message": "Hello", "opened_at": 1650000000.0}
],
"recent_dialogs": [
{"id": "d-1", "type": "alert", "message": "...", "opened_at": 1650000000.0,
"closed_at": 1650000000.1, "closed_by": "remote"}
],
"frame_tree": {
"top": {"frame_id": "FRAME_A", "url": "https://example.com/", "origin": "https://example.com"},
"children": [
{"frame_id": "FRAME_B", "url": "about:srcdoc", "is_oopif": false},
{"frame_id": "FRAME_C", "url": "https://ads.example.net/", "is_oopif": true, "session_id": "SID_C"}
],
"truncated": false
}
}
```
- **`pending_dialogs`**:当前阻塞页面 JS 线程的对话框。Agent 必须调用 `browser_dialog(action=...)` 进行响应。在 Browserbase 上为空,因为其 CDP 代理会在约 10ms 内自动关闭对话框。
- **`recent_dialogs`**:最近关闭的最多 20 个对话框的环形缓冲区,带有 `closed_by` 标签——`"agent"`(我们响应了)、`"auto_policy"`(本地 auto_dismiss/auto_accept)、`"watchdog"`must_respond 超时触发)或 `"remote"`(浏览器/后端主动关闭,例如 Browserbase)。这是 Browserbase 上的 agent 仍能了解发生了什么的方式。
- **`frame_tree`**:框架结构,包括跨域(OOPIF)子框架。上限为 30 条 + OOPIF 深度 2,以限制广告密集页面上的快照大小。当达到限制时,`truncated: true` 会出现;需要完整树的 agent 可使用 `browser_cdp` 配合 `Page.getFrameTree`
以上均不新增工具 schema 接口——agent 从其已请求的快照中读取。
### 可用性门控
两个接口均通过 `_browser_cdp_check` 进行门控(supervisor 只能在 CDP 端点可达时运行)。在 Camofox / 无后端会话中,对话框工具被隐藏,快照省略新字段——不产生 schema 膨胀。
## 跨域 iframe 交互
在对话框检测工作的基础上,`browser_cdp(frame_id=...)` 通过 supervisor 已连接的 WebSocket,使用 OOPIF 的子 `sessionId` 路由 CDP 调用(尤其是 `Runtime.evaluate`)。Agent 从 `browser_snapshot.frame_tree.children[]``is_oopif=true` 的条目获取 frame_id,并将其传递给 `browser_cdp`。对于同源 iframe(无专用 CDP 会话),agent 改用顶层 `Runtime.evaluate` 中的 `contentWindow`/`contentDocument`——当 `frame_id` 属于非 OOPIF 时,supervisor 会返回指向该回退方案的错误。
在 Browserbase 上,这是 iframe 交互的**唯一**可靠路径——无状态 CDP 连接(每次 `browser_cdp` 调用时打开)会遭遇签名 URL 过期,而 supervisor 的长连接则保持有效会话。
## Camofox(后续跟进)
计划向 `jo-inc/camofox-browser` 提交 issue,添加:
- 每个会话的 Playwright `page.on('dialog', handler)`
- `GET /tabs/:tabId/dialogs` 轮询端点
- `POST /tabs/:tabId/dialogs/:id` 用于接受/关闭
- 框架树内省端点
## 涉及文件(PR 1
### 新增
- `tools/browser_supervisor.py``CDPSupervisor``SupervisorRegistry``PendingDialog``FrameInfo`
- `tools/browser_dialog_tool.py``browser_dialog` 工具处理器
- `tests/tools/test_browser_supervisor.py` — 模拟 CDP WebSocket 服务器 + 生命周期/状态测试
- `website/docs/developer-guide/browser-supervisor.md` — 本文件
### 修改
- `toolsets.py` — 在 `browser``hermes-acp``hermes-api-server`、核心工具集中注册 `browser_dialog`(通过 CDP 可达性门控)
- `tools/browser_tool.py`
- `browser_navigate` 启动钩子:若 CDP URL 可解析,调用 `SupervisorRegistry.get_or_start(task_id, cdp_url)`
- `browser_snapshot`(约第 1536 行):将 supervisor 状态合并到返回载荷
- `/browser connect` 处理器:以新端点重启 supervisor
- `_cleanup_browser_session` 中的会话拆除钩子
- `hermes_cli/config.py` — 向 `DEFAULT_CONFIG` 添加 `browser.dialog_policy``browser.dialog_timeout_s`
- 文档:`website/docs/user-guide/features/browser.md``website/docs/reference/tools-reference.md``website/docs/reference/toolsets-reference.md`
## 非目标
- Camofox 的检测/交互(上游缺口;单独跟踪)
- 向用户实时流式传输对话框/框架事件(需要 gateway 钩子)
- 跨会话持久化对话框历史(仅内存)
- 按 iframe 配置对话框策略(agent 可通过 `dialog_id` 表达)
- 替换 `browser_cdp`——它作为长尾场景(cookies、viewport、网络限速)的逃生舱口继续保留
## 测试
单元测试使用 asyncio 模拟 CDP 服务器,该服务器实现了足够的协议子集,以覆盖所有状态转换:附加、启用、导航、对话框触发、对话框关闭、框架附加/分离、子 target 附加、会话拆除。真实后端端到端测试(Browserbase + 本地 Chromium 系浏览器)为手动执行——通过 `/browser connect` 连接到实时 Chromium 系浏览器,并运行上述对话框/框架测试用例。
@@ -0,0 +1,326 @@
---
title: 上下文压缩与缓存
description: Hermes Agent 如何通过双重压缩系统和 Anthropic prompt 缓存高效管理上下文窗口。
---
# 上下文压缩与缓存
Hermes Agent 使用双重压缩系统和 Anthropic prompt(提示词)缓存,在长对话中高效管理上下文窗口用量。
源文件:`agent/context_engine.py`ABC)、`agent/context_compressor.py`(默认引擎)、
`agent/prompt_caching.py``gateway/run.py`(会话清理)、`run_agent.py`(搜索 `_compress_context`
## 可插拔上下文引擎
上下文管理基于 `ContextEngine` ABC`agent/context_engine.py`)构建。内置的 `ContextCompressor` 是默认实现,但插件可以用其他引擎替换它(例如无损上下文管理)。
```yaml
context:
engine: "compressor" # default — built-in lossy summarization
engine: "lcm" # example — plugin providing lossless context
```
引擎负责:
- 决定何时触发压缩(`should_compress()`
- 执行压缩(`compress()`
- 可选地暴露 agent 可调用的工具(例如 `lcm_grep`
- 追踪 API 响应中的 token 用量
通过 `config.yaml` 中的 `context.engine` 进行配置驱动选择。解析顺序:
1. 检查 `plugins/context_engine/<name>/` 目录
2. 检查通用插件系统(`register_context_engine()`
3. 回退到内置 `ContextCompressor`
插件引擎**永远不会自动激活**——用户必须在 `context.engine` 中显式设置插件名称。默认的 `"compressor"` 始终使用内置实现。
通过 `hermes plugins` → Provider Plugins → Context Engine 进行配置,或直接编辑 `config.yaml`
关于构建上下文引擎插件,请参阅 [Context Engine 插件](/developer-guide/context-engine-plugin)。
## 双重压缩系统
Hermes 有两个独立运行的压缩层:
```
┌──────────────────────────┐
Incoming message │ Gateway Session Hygiene │ Fires at 85% of context
─────────────────► │ (pre-agent, rough est.) │ Safety net for large sessions
└─────────────┬────────────┘
┌──────────────────────────┐
│ Agent ContextCompressor │ Fires at 50% of context (default)
│ (in-loop, real tokens) │ Normal context management
└──────────────────────────┘
```
### 1. Gateway 会话清理(85% 阈值)
位于 `gateway/run.py`(搜索 `Session hygiene: auto-compress`)。这是一个**安全网**,在 agent 处理消息之前运行。它防止会话在两次交互之间增长过大时(例如 Telegram/Discord 中的隔夜积累)导致 API 失败。
- **阈值**:固定为模型上下文长度的 85%
- **Token 来源**:优先使用上一轮 API 实际报告的 token 数;回退到基于字符的粗略估算(`estimate_messages_tokens_rough`
- **触发条件**:仅当 `len(history) >= 4` 且压缩已启用时
- **目的**:捕获逃过 agent 自身压缩器的会话
Gateway 清理阈值有意高于 agent 压缩器的阈值。将其设置为 50%(与 agent 相同)会导致长 gateway 会话在每一轮都过早触发压缩。
### 2. Agent ContextCompressor50% 阈值,可配置)
位于 `agent/context_compressor.py`。这是**主要压缩系统**,在 agent 的工具循环内运行,可访问准确的 API 报告 token 数。
## 配置
所有压缩设置从 `config.yaml``compression` 键读取:
```yaml
compression:
enabled: true # Enable/disable compression (default: true)
threshold: 0.50 # Fraction of context window (default: 0.50 = 50%)
target_ratio: 0.20 # How much of threshold to keep as tail (default: 0.20)
protect_last_n: 20 # Minimum protected tail messages (default: 20)
# Summarization model/provider configured under auxiliary:
auxiliary:
compression:
model: null # Override model for summaries (default: auto-detect)
provider: auto # Provider: "auto", "openrouter", "nous", "main", etc.
base_url: null # Custom OpenAI-compatible endpoint
```
### 参数详情
| 参数 | 默认值 | 范围 | 描述 |
|-----------|---------|-------|-------------|
| `threshold` | `0.50` | 0.0-1.0 | 当 prompt token 数 ≥ `threshold × context_length` 时触发压缩 |
| `target_ratio` | `0.20` | 0.10-0.80 | 控制尾部保护 token 预算:`threshold_tokens × target_ratio` |
| `protect_last_n` | `20` | ≥1 | 始终保留的最近消息最小数量 |
| `protect_first_n` | `3` | (硬编码)| 系统提示词 + 首次交互始终保留 |
### 计算值(200K 上下文模型,默认参数)
```
context_length = 200,000
threshold_tokens = 200,000 × 0.50 = 100,000
tail_token_budget = 100,000 × 0.20 = 20,000
max_summary_tokens = min(200,000 × 0.05, 12,000) = 10,000
```
## 压缩算法
`ContextCompressor.compress()` 方法遵循 4 阶段算法:
### 阶段 1:清除旧工具结果(廉价,无需 LLM 调用)
保护尾部之外的旧工具结果(>200 字符)将被替换为:
```
[Old tool output cleared to save context space]
```
这是一个廉价的预处理步骤,可从冗长的工具输出(文件内容、终端输出、搜索结果)中节省大量 token。
### 阶段 2:确定边界
```
┌─────────────────────────────────────────────────────────────┐
│ Message list │
│ │
│ [0..2] ← protect_first_n (system + first exchange) │
│ [3..N] ← middle turns → SUMMARIZED │
│ [N..end] ← tail (by token budget OR protect_last_n) │
│ │
└─────────────────────────────────────────────────────────────┘
```
尾部保护基于 **token 预算**:从末尾向前遍历,累积 token 直到预算耗尽。如果预算保护的消息数少于固定的 `protect_last_n`,则回退到该固定数量。
边界对齐以避免拆分 tool_call/tool_result 组。`_align_boundary_backward()` 方法会跳过连续的工具结果,找到父级 assistant 消息,保持组的完整性。
### 阶段 3:生成结构化摘要
:::warning 摘要模型上下文长度
摘要模型的上下文窗口必须**至少与主 agent 模型一样大**。整个中间部分通过单次 `call_llm(task="compression")` 调用发送给摘要模型。如果摘要模型的上下文更小,API 将返回上下文长度错误——`_generate_summary()` 会捕获该错误,记录警告并返回 `None`。压缩器随后会**在没有摘要的情况下丢弃中间轮次**,静默丢失对话上下文。这是压缩质量下降最常见的原因。
:::
中间轮次使用辅助 LLM 以结构化模板进行摘要:
```
## Goal
[What the user is trying to accomplish]
## Constraints & Preferences
[User preferences, coding style, constraints, important decisions]
## Progress
### Done
[Completed work — specific file paths, commands run, results]
### In Progress
[Work currently underway]
### Blocked
[Any blockers or issues encountered]
## Key Decisions
[Important technical decisions and why]
## Relevant Files
[Files read, modified, or created — with brief note on each]
## Next Steps
[What needs to happen next]
## Critical Context
[Specific values, error messages, configuration details]
```
摘要预算随被压缩内容的量动态调整:
- 公式:`content_tokens × 0.20``_SUMMARY_RATIO` 常量)
- 最小值:2,000 token
- 最大值:`min(context_length × 0.05, 12,000)` token
### 阶段 4:组装压缩后的消息
压缩后的消息列表为:
1. 头部消息(首次压缩时在系统提示词后追加一条说明)
2. 摘要消息(角色经过选择以避免连续相同角色违规)
3. 尾部消息(未修改)
`_sanitize_tool_pairs()` 清理孤立的 tool_call/tool_result 对:
- 引用已删除调用的工具结果 → 删除
- 结果已被删除的工具调用 → 注入存根结果
### 迭代重压缩
在后续压缩中,前一次摘要会连同指令一起传递给 LLM,要求其**更新**摘要而非从头摘要。这在多次压缩中保留了信息——条目从"进行中"移至"已完成",新进展被添加,过时信息被删除。
压缩器实例上的 `_previous_summary` 字段存储最后一次摘要文本以供此用途。
## 压缩前后示例
### 压缩前(45 条消息,约 95K token
```
[0] system: "You are a helpful assistant..." (system prompt)
[1] user: "Help me set up a FastAPI project"
[2] assistant: <tool_call> terminal: mkdir project </tool_call>
[3] tool: "directory created"
[4] assistant: <tool_call> write_file: main.py </tool_call>
[5] tool: "file written (2.3KB)"
... 30 more turns of file editing, testing, debugging ...
[38] assistant: <tool_call> terminal: pytest </tool_call>
[39] tool: "8 passed, 2 failed\n..." (5KB output)
[40] user: "Fix the failing tests"
[41] assistant: <tool_call> read_file: tests/test_api.py </tool_call>
[42] tool: "import pytest\n..." (3KB)
[43] assistant: "I see the issue with the test fixtures..."
[44] user: "Great, also add error handling"
```
### 压缩后(25 条消息,约 45K token
```
[0] system: "You are a helpful assistant...
[Note: Some earlier conversation turns have been compacted...]"
[1] user: "Help me set up a FastAPI project"
[2] assistant: "[CONTEXT COMPACTION] Earlier turns were compacted...
## Goal
Set up a FastAPI project with tests and error handling
## Progress
### Done
- Created project structure: main.py, tests/, requirements.txt
- Implemented 5 API endpoints in main.py
- Wrote 10 test cases in tests/test_api.py
- 8/10 tests passing
### In Progress
- Fixing 2 failing tests (test_create_user, test_delete_user)
## Relevant Files
- main.py — FastAPI app with 5 endpoints
- tests/test_api.py — 10 test cases
- requirements.txt — fastapi, pytest, httpx
## Next Steps
- Fix failing test fixtures
- Add error handling"
[3] user: "Fix the failing tests"
[4] assistant: <tool_call> read_file: tests/test_api.py </tool_call>
[5] tool: "import pytest\n..."
[6] assistant: "I see the issue with the test fixtures..."
[7] user: "Great, also add error handling"
```
## Prompt 缓存(Anthropic
来源:`agent/prompt_caching.py`
通过缓存对话前缀,在多轮对话中将输入 token 成本降低约 75%。使用 Anthropic 的 `cache_control` 断点。
### 策略:system_and_3
Anthropic 每次请求最多允许 4 个 `cache_control` 断点。Hermes 使用"system_and_3"策略:
```
Breakpoint 1: System prompt (stable across all turns)
Breakpoint 2: 3rd-to-last non-system message ─┐
Breakpoint 3: 2nd-to-last non-system message ├─ Rolling window
Breakpoint 4: Last non-system message ─┘
```
### 工作原理
`apply_anthropic_cache_control()` 深拷贝消息并注入 `cache_control` 标记:
```python
# Cache marker format
marker = {"type": "ephemeral"}
# Or for 1-hour TTL:
marker = {"type": "ephemeral", "ttl": "1h"}
```
标记根据内容类型以不同方式应用:
| 内容类型 | 标记位置 |
|-------------|-------------------|
| 字符串内容 | 转换为 `[{"type": "text", "text": ..., "cache_control": ...}]` |
| 列表内容 | 添加到最后一个元素的字典中 |
| None/空 | 作为 `msg["cache_control"]` 添加 |
| 工具消息 | 作为 `msg["cache_control"]` 添加(仅限原生 Anthropic |
### 缓存感知设计模式
1. **稳定的系统提示词**:系统提示词是断点 1,在所有轮次中缓存。避免在对话中途修改它(压缩仅在首次压缩时追加一条说明)。
2. **消息顺序很重要**:缓存命中需要前缀匹配。在中间添加或删除消息会使其后所有内容的缓存失效。
3. **压缩与缓存的交互**:压缩后,被压缩区域的缓存失效,但系统提示词缓存保留。滚动 3 消息窗口在 1-2 轮内重新建立缓存。
4. **TTL 选择**:默认为 `5m`(5 分钟)。对于用户在轮次之间有较长间隔的长时间会话,使用 `1h`
### 启用 Prompt 缓存
满足以下条件时,prompt 缓存自动启用:
- 模型为 Anthropic Claude 模型(通过模型名称检测)
- 提供商支持 `cache_control`(原生 Anthropic API 或 OpenRouter
```yaml
# config.yaml — TTL is configurable (must be "5m" or "1h")
prompt_caching:
cache_ttl: "5m"
```
CLI 在启动时显示缓存状态:
```
💾 Prompt caching: ENABLED (Claude via OpenRouter, 5m TTL)
```
## 上下文压力警告
中间上下文压力警告已被移除(参见 `run_agent.py` 中的迭代预算块,其中注明:"No intermediate pressure warnings — they caused models to 'give up' prematurely on complex tasks")。压缩在 prompt token 达到配置的 `compression.threshold`(默认 50%)时触发,无需事先警告步骤;gateway 会话清理作为二级安全网在模型上下文窗口的 85% 处触发。
@@ -0,0 +1,193 @@
---
sidebar_position: 9
title: "Context Engine 插件"
description: "如何构建替换内置 ContextCompressor 的 context engine 插件"
---
# 构建 Context Engine 插件
Context engine 插件用于替换内置的 `ContextCompressor`,以实现管理对话上下文的替代策略。例如,无损上下文管理(LCM)引擎通过构建知识 DAG 来替代有损摘要。
## 工作原理
Agent 的上下文管理基于 `ContextEngine` ABC`agent/context_engine.py`)构建。内置的 `ContextCompressor` 是默认实现。插件引擎必须实现相同的接口。
同一时间只能有**一个** context engine 处于激活状态。选择由配置驱动:
```yaml
# config.yaml
context:
engine: "compressor" # 默认内置
engine: "lcm" # 激活名为 "lcm" 的插件引擎
```
插件引擎**永远不会自动激活** — 用户必须显式将 `context.engine` 设置为插件名称。
## 目录结构
每个 context engine 位于 `plugins/context_engine/<name>/`
```
plugins/context_engine/lcm/
├── __init__.py # 导出 ContextEngine 子类
├── plugin.yaml # 元数据(name、description、version
└── ... # 引擎所需的其他模块
```
## ContextEngine ABC
你的引擎必须实现以下**必需**方法:
```python
from agent.context_engine import ContextEngine
class LCMEngine(ContextEngine):
@property
def name(self) -> str:
"""短标识符,例如 'lcm'。必须与 config.yaml 中的值匹配。"""
return "lcm"
def update_from_response(self, usage: dict) -> None:
"""每次 LLM 调用后,以 usage dict 为参数调用。
从响应中更新 self.last_prompt_tokens、self.last_completion_tokens、
self.last_total_tokens。
"""
def should_compress(self, prompt_tokens: int = None) -> bool:
"""若本轮应触发压缩则返回 True。"""
def compress(self, messages: list, current_tokens: int = None,
focus_topic: str = None) -> list:
"""压缩消息列表并返回新的(可能更短的)列表。
返回的列表必须是有效的 OpenAI 格式消息序列。
``focus_topic`` 是来自手动 ``/compress <focus>`` 的可选主题字符串;
支持引导式压缩的引擎应优先保留与其相关的信息,其他引擎可忽略。
"""
```
### 引擎必须维护的类属性
Agent 直接读取这些属性用于显示和日志记录:
```python
last_prompt_tokens: int = 0
last_completion_tokens: int = 0
last_total_tokens: int = 0
threshold_tokens: int = 0 # 触发压缩的阈值
context_length: int = 0 # 模型的完整上下文窗口
compression_count: int = 0 # compress() 已运行的次数
```
### 可选方法
这些方法在 ABC 中有合理的默认实现,按需覆盖:
| 方法 | 默认行为 | 何时覆盖 |
|--------|---------|--------------|
| `on_session_start(session_id, **kwargs)` | 空操作 | 需要加载持久化状态(DAG、DB)时 |
| `on_session_end(session_id, messages)` | 空操作 | 需要刷新状态、关闭连接时 |
| `on_session_reset()` | 重置 token 计数器 | 有需要清除的会话级状态时 |
| `update_model(model, context_length, ...)` | 更新 context_length 和阈值 | 需要在切换模型时重新计算预算时 |
| `get_tool_schemas()` | 返回 `[]` | 引擎提供 agent 可调用的工具时(例如 `lcm_grep` |
| `handle_tool_call(name, args, **kwargs)` | 返回错误 JSON | 实现工具处理器时 |
| `should_compress_preflight(messages)` | 返回 `False` | 可在 API 调用前进行低成本预估时 |
| `get_status()` | 标准 token/阈值字典 | 有自定义指标需要暴露时 |
## 引擎工具
Context engine 可以暴露 agent 直接调用的工具。从 `get_tool_schemas()` 返回 schema,并在 `handle_tool_call()` 中处理调用:
```python
def get_tool_schemas(self):
return [{
"name": "lcm_grep",
"description": "Search the context knowledge graph",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"}
},
"required": ["query"],
},
}]
def handle_tool_call(self, name, args, **kwargs):
if name == "lcm_grep":
results = self._search_dag(args["query"])
return json.dumps({"results": results})
return json.dumps({"error": f"Unknown tool: {name}"})
```
引擎工具在启动时注入到 agent 的工具列表中并自动分发 — 无需注册到注册表。
## 注册
### 通过目录(推荐)
将引擎放置于 `plugins/context_engine/<name>/``__init__.py` 必须导出一个 `ContextEngine` 子类。发现系统会自动找到并实例化它。
### 通过通用插件系统
通用插件也可以注册 context engine
```python
def register(ctx):
engine = LCMEngine(context_length=200000)
ctx.register_context_engine(engine)
```
只能注册一个引擎。第二个尝试注册的插件将被拒绝并发出警告。
## 生命周期
```
1. 引擎实例化(插件加载或目录发现)
2. on_session_start() — 对话开始
3. update_from_response() — 每次 API 调用后
4. should_compress() — 每轮检查
5. compress() — 当 should_compress() 返回 True 时调用
6. on_session_end() — 会话边界(CLI 退出、/reset、gateway 过期)
```
`on_session_reset()``/new``/reset` 时调用,用于清除会话级状态而不完全关闭。
## 配置
用户通过 `hermes plugins` → Provider Plugins → Context Engine 选择引擎,或直接编辑 `config.yaml`
```yaml
context:
engine: "lcm" # 必须与引擎的 name 属性匹配
```
`compression` 配置块(`compression.threshold``compression.protect_last_n` 等)专属于内置的 `ContextCompressor`。如有需要,你的引擎应定义自己的配置格式,并在初始化期间从 `config.yaml` 读取。
## 测试
```python
from agent.context_engine import ContextEngine
def test_engine_satisfies_abc():
engine = YourEngine(context_length=200000)
assert isinstance(engine, ContextEngine)
assert engine.name == "your-name"
def test_compress_returns_valid_messages():
engine = YourEngine(context_length=200000)
msgs = [{"role": "user", "content": "hello"}]
result = engine.compress(msgs)
assert isinstance(result, list)
assert all("role" in m for m in result)
```
完整的 ABC 契约测试套件请参见 `tests/agent/test_context_engine.py`
## 另请参阅
- [上下文压缩与缓存](/developer-guide/context-compression-and-caching) — 内置压缩器的工作原理
- [Memory Provider 插件](/developer-guide/memory-provider-plugin) — 类似的单选插件系统(用于内存)
- [插件](/user-guide/features/plugins) — 通用插件系统概述
@@ -0,0 +1,243 @@
---
sidebar_position: 4
title: "贡献指南"
description: "如何为 Hermes Agent 做贡献 — 开发环境配置、代码风格、PR 流程"
---
# 贡献指南
感谢您为 Hermes Agent 做贡献!本指南涵盖开发环境配置、代码库结构说明以及 PR 合并流程。
## 贡献优先级
我们按以下顺序评估贡献价值:
1. **Bug 修复** — 崩溃、错误行为、数据丢失
2. **跨平台兼容性** — macOS、不同 Linux 发行版、WSL2
3. **安全加固** — shell 注入、prompt(提示词)注入、路径穿越
4. **性能与健壮性** — 重试逻辑、错误处理、优雅降级
5. **新 skill** — 具有广泛用途的 skill(参见 [创建 Skill](creating-skills.md)
6. **新工具** — 极少需要;大多数能力应以 skill 形式实现
7. **文档** — 修正、说明、新示例
## 常见贡献路径
- 构建自定义/本地工具而不修改 Hermes 核心?从 [构建 Hermes 插件](../guides/build-a-hermes-plugin.md) 开始
- 为 Hermes 本身构建新的内置核心工具?从 [添加工具](./adding-tools.md) 开始
- 构建新的 skill?从 [创建 Skill](./creating-skills.md) 开始
- 构建新的推理提供商?从 [添加提供商](./adding-providers.md) 开始
## 开发环境配置
### 前置要求
| 要求 | 说明 |
|-------------|-------|
| **Git** | 需安装 `git-lfs` 扩展 |
| **Python 3.11+** | 若未安装,uv 会自动安装 |
| **uv** | 高速 Python 包管理器([安装](https://docs.astral.sh/uv/) |
| **Node.js 20+** | 可选 — 浏览器工具和 WhatsApp bridge 需要(与根目录 `package.json` engines 字段一致) |
### 克隆与安装
```bash
git clone https://github.com/NousResearch/hermes-agent.git
cd hermes-agent
# 使用 Python 3.11 创建虚拟环境
uv venv venv --python 3.11
export VIRTUAL_ENV="$(pwd)/venv"
# 安装所有扩展(messaging、cron、CLI 菜单、开发工具)
uv pip install -e ".[all,dev]"
# 可选:浏览器工具
npm install
```
### 配置开发环境
```bash
mkdir -p ~/.hermes/{cron,sessions,logs,memories,skills}
cp cli-config.yaml.example ~/.hermes/config.yaml
touch ~/.hermes/.env
# 至少添加一个 LLM 提供商密钥:
echo 'OPENROUTER_API_KEY=sk-or-v1-your-key' >> ~/.hermes/.env
```
### 运行
```bash
# 创建全局访问的符号链接
mkdir -p ~/.local/bin
ln -sf "$(pwd)/venv/bin/hermes" ~/.local/bin/hermes
# 验证
hermes doctor
hermes chat -q "Hello"
```
### 运行测试
```bash
pytest tests/ -v
```
## 代码风格
- **PEP 8**,允许合理例外(不强制限制行长度)
- **注释**:仅在解释非显而易见的意图、权衡取舍或 API 特殊行为时添加
- **错误处理**:捕获具体异常。对于意外错误,使用 `logger.warning()`/`logger.error()` 并设置 `exc_info=True`
- **跨平台**:不得假设 Unix 环境(见下文)
- **Profile 安全路径**:不得硬编码 `~/.hermes` — 代码路径使用 `hermes_constants` 中的 `get_hermes_home()`,面向用户的消息使用 `display_hermes_home()`。完整规则参见 [AGENTS.md](https://github.com/NousResearch/hermes-agent/blob/main/AGENTS.md#profiles-multi-instance-support)。
## 跨平台兼容性
Hermes 官方支持 **Linux、macOS、WSL2 以及原生 Windows(通过 PowerShell 安装)**。原生 Windows 使用 [Git for Windows](https://git-scm.com/download/win) 提供的 Git Bash 执行 shell 命令。部分功能依赖 POSIX 内核原语,已做条件限制:dashboard 内嵌的 PTY 终端面板(`/chat` 标签页)仅支持 WSL2。如果您主要在 Windows 上开发,推送前请运行 Windows 陷阱(footgunlint`scripts/check-windows-footguns.py`)。
贡献代码时,请遵守以下规则:
- **不得添加未加保护的 `signal.SIGKILL` 引用。** Windows 上未定义该信号。请通过 `gateway.status.terminate_pid(pid, force=True)`(集中式原语,Windows 上执行 `taskkill /T /F`POSIX 上发送 SIGKILL)路由,或使用 `getattr(signal, "SIGKILL", signal.SIGTERM)` 回退。
- **在 `os.kill(pid, 0)` 探测时同时捕获 `OSError``ProcessLookupError`。** Windows 对已消失的 PID 抛出 `OSError`WinError 87"参数不正确"),而非 `ProcessLookupError`
- **不得强制终端使用 POSIX 语义。** `os.setsid``os.killpg``os.getpgid``os.fork` 在 Windows 上均会抛出异常 — 使用 `if sys.platform != "win32":``if os.name != "nt":` 进行条件判断。
- **打开文件时显式指定 `encoding="utf-8"`。** Windows 上 Python 默认使用系统区域设置(通常为 cp1252),处理非拉丁字符时会出现乱码或崩溃。
- **使用 `pathlib.Path` / `os.path.join`,不得手动用 `/` 拼接路径。** 这对我们构造后传给子进程的字符串尤为重要,而非 OS 返回给我们的字符串。
关键模式:
### 1. `termios` 和 `fcntl` 仅适用于 Unix
始终同时捕获 `ImportError``NotImplementedError`
```python
try:
from simple_term_menu import TerminalMenu
menu = TerminalMenu(options)
idx = menu.show()
except (ImportError, NotImplementedError):
# 回退:编号菜单
for i, opt in enumerate(options):
print(f" {i+1}. {opt}")
idx = int(input("Choice: ")) - 1
```
### 2. 文件编码
某些环境可能以非 UTF-8 编码保存 `.env` 文件:
```python
try:
load_dotenv(env_path)
except UnicodeDecodeError:
load_dotenv(env_path, encoding="latin-1")
```
### 3. 进程管理
`os.setsid()``os.killpg()` 以及信号处理在各平台间存在差异:
```python
import platform
if platform.system() != "Windows":
kwargs["preexec_fn"] = os.setsid
```
### 4. 路径分隔符
使用 `pathlib.Path` 代替用 `/` 进行字符串拼接。
## 安全注意事项
Hermes 拥有终端访问权限,安全至关重要。
### 现有保护措施
| 层级 | 实现方式 |
|-------|---------------|
| **sudo 密码管道** | 使用 `shlex.quote()` 防止 shell 注入 |
| **危险命令检测** | `tools/approval.py` 中的正则表达式模式,配合用户审批流程 |
| **Cron prompt 注入** | 扫描器阻断指令覆盖模式 |
| **写入拒绝列表** | 受保护路径通过 `os.path.realpath()` 解析,防止符号链接绕过 |
| **Skill 守卫** | 对 hub 安装的 skill 进行安全扫描 |
| **代码执行沙箱** | 子进程运行时剥离 API 密钥 |
| **容器加固** | Docker:删除所有 capability,禁止权限提升,限制 PID 数量 |
### 贡献安全敏感代码
- 将用户输入插入 shell 命令时,始终使用 `shlex.quote()`
- 访问控制检查前,使用 `os.path.realpath()` 解析符号链接
- 不得记录密钥信息
- 在工具执行周围捕获宽泛异常
- 若您的变更涉及文件路径或进程,请在所有平台上测试
## Pull Request 流程
### 分支命名
```
fix/description # Bug 修复
feat/description # 新功能
docs/description # 文档
test/description # 测试
refactor/description # 代码重构
```
### 提交前检查
1. **运行测试**`pytest tests/ -v`
2. **手动测试**:运行 `hermes` 并验证您修改的代码路径
3. **检查跨平台影响**:考虑 macOS 和不同 Linux 发行版
4. **保持 PR 聚焦**:每个 PR 只包含一个逻辑变更
### PR 描述
请包含:
- **变更内容**及**变更原因**
- **测试方法**
- **测试平台**
- 关联 issue 引用
### Commit 消息
我们使用 [Conventional Commits](https://www.conventionalcommits.org/)
```
<type>(<scope>): <description>
```
| 类型 | 适用场景 |
|------|---------|
| `fix` | Bug 修复 |
| `feat` | 新功能 |
| `docs` | 文档 |
| `test` | 测试 |
| `refactor` | 代码重构 |
| `chore` | 构建、CI、依赖更新 |
Scope 范围:`cli``gateway``tools``skills``agent``install``whatsapp``security`
示例:
```
fix(cli): prevent crash in save_config_value when model is a string
feat(gateway): add WhatsApp multi-user session isolation
fix(security): prevent shell injection in sudo password piping
```
## 报告问题
- 使用 [GitHub Issues](https://github.com/NousResearch/hermes-agent/issues)
- 请包含:操作系统、Python 版本、Hermes 版本(`hermes version`)、完整错误堆栈
- 包含复现步骤
- 创建前请检查是否已有重复 issue
- 安全漏洞请私下报告
## 社区
- **Discord**[discord.gg/NousResearch](https://discord.gg/NousResearch)
- **GitHub Discussions**:用于设计提案和架构讨论
- **Skills Hub**:上传专业 skill 并与社区共享
## 许可证
提交贡献即表示您同意您的贡献将以 [MIT 许可证](https://github.com/NousResearch/hermes-agent/blob/main/LICENSE) 授权。
@@ -0,0 +1,375 @@
---
sidebar_position: 3
title: "创建 Skill"
description: "如何为 Hermes Agent 创建 skill——SKILL.md 格式、规范与发布"
---
# 创建 Skill
Skill 是为 Hermes Agent 添加新能力的首选方式。与 tool 相比,skill 更易于创建,无需修改 agent 代码,且可与社区共享。
## 应该创建 Skill 还是 Tool
以下情况创建 **Skill**
- 该能力可通过指令 + shell 命令 + 现有 tool 来实现
- 封装了 agent 可通过 `terminal``web_extract` 调用的外部 CLI 或 API
- 不需要将自定义 Python 集成或 API key 管理内置到 agent 中
- 示例:arXiv 搜索、git 工作流、Docker 管理、PDF 处理、通过 CLI 工具发送邮件
以下情况创建 **Tool**
- 需要与 API key、认证流程或多组件配置进行端到端集成
- 需要每次精确执行的自定义处理逻辑
- 处理二进制数据、流式传输或实时事件
- 示例:浏览器自动化、TTS、视觉分析
## Skill 目录结构
内置 skill 位于 `skills/` 目录下,按类别组织。官方可选 skill 在 `optional-skills/` 中使用相同结构:
```text
skills/
├── research/
│ └── arxiv/
│ ├── SKILL.md # 必需:主要指令
│ └── scripts/ # 可选:辅助脚本
│ └── search_arxiv.py
├── productivity/
│ └── ocr-and-documents/
│ ├── SKILL.md
│ ├── scripts/
│ └── references/
└── ...
```
## SKILL.md 格式
```markdown
---
name: my-skill
description: Brief description (shown in skill search results)
version: 1.0.0
author: Your Name
license: MIT
platforms: [macos, linux] # Optional — restrict to specific OS platforms
# Valid: macos, linux, windows
# Omit to load on all platforms (default)
metadata:
hermes:
tags: [Category, Subcategory, Keywords]
related_skills: [other-skill-name]
requires_toolsets: [web] # Optional — only show when these toolsets are active
requires_tools: [web_search] # Optional — only show when these tools are available
fallback_for_toolsets: [browser] # Optional — hide when these toolsets are active
fallback_for_tools: [browser_navigate] # Optional — hide when these tools exist
config: # Optional — config.yaml settings the skill needs
- key: my.setting
description: "What this setting controls"
default: "sensible-default"
prompt: "Display prompt for setup"
required_environment_variables: # Optional — env vars the skill needs
- name: MY_API_KEY
prompt: "Enter your API key"
help: "Get one at https://example.com"
required_for: "API access"
---
# Skill Title
Brief intro.
## When to Use
Trigger conditions — when should the agent load this skill?
## Quick Reference
Table of common commands or API calls.
## Procedure
Step-by-step instructions the agent follows.
## Pitfalls
Known failure modes and how to handle them.
## Verification
How the agent confirms it worked.
```
### 平台专属 Skill
Skill 可通过 `platforms` 字段将自身限制在特定操作系统上:
```yaml
platforms: [macos] # 仅 macOS(例如 iMessage、Apple Reminders
platforms: [macos, linux] # macOS 和 Linux
platforms: [windows] # 仅 Windows
```
设置后,该 skill 会在不兼容的平台上自动从系统 prompt(提示词)、`skills_list()` 和斜杠命令中隐藏。若省略或留空,则在所有平台上加载(向后兼容)。
### 条件式 Skill 激活
Skill 可声明对特定 tool 或 toolset 的依赖,以控制该 skill 是否出现在当前会话的系统 prompt 中。
```yaml
metadata:
hermes:
requires_toolsets: [web] # 若 web toolset 未激活则隐藏
requires_tools: [web_search] # 若 web_search tool 不可用则隐藏
fallback_for_toolsets: [browser] # 若 browser toolset 已激活则隐藏
fallback_for_tools: [browser_navigate] # 若 browser_navigate 可用则隐藏
```
| 字段 | 行为 |
|-------|----------|
| `requires_toolsets` | 当列出的**任意** toolset **不**可用时,skill **隐藏** |
| `requires_tools` | 当列出的**任意** tool **不**可用时,skill **隐藏** |
| `fallback_for_toolsets` | 当列出的**任意** toolset **已**可用时,skill **隐藏** |
| `fallback_for_tools` | 当列出的**任意** tool **已**可用时,skill **隐藏** |
**`fallback_for_*` 使用场景:** 创建一个在主要 tool 不可用时作为替代方案的 skill。例如,带有 `fallback_for_tools: [web_search]``duckduckgo-search` skill 仅在未配置需要 API key 的 web search tool 时显示。
**`requires_*` 使用场景:** 创建仅在特定 tool 存在时才有意义的 skill。例如,带有 `requires_toolsets: [web]` 的网页抓取工作流 skill 在 web tool 被禁用时不会出现在 prompt 中。
### 环境变量要求
Skill 可声明所需的环境变量。当通过 `skill_view` 加载 skill 时,其所需变量会自动注册,以便透传(passthrough)到沙箱执行环境(terminal、execute_code)中。
```yaml
required_environment_variables:
- name: TENOR_API_KEY
prompt: "Tenor API key" # 提示用户时显示
help: "Get your key at https://tenor.com" # 帮助文本或 URL
required_for: "GIF search functionality" # 哪个功能需要此变量
```
每个条目支持:
- `name`(必需)——环境变量名称
- `prompt`(可选)——向用户询问值时的提示文本
- `help`(可选)——获取该值的帮助文本或 URL
- `required_for`(可选)——描述哪个功能需要此变量
用户也可在 `config.yaml` 中手动配置透传变量:
```yaml
terminal:
env_passthrough:
- MY_CUSTOM_VAR
- ANOTHER_VAR
```
macOS 专属 skill 示例请参见 `skills/apple/`
## 加载时的安全配置
当 skill 需要 API key 或 token 时,使用 `required_environment_variables`。缺少值**不会**将 skill 从发现列表中隐藏。Hermes 会在本地 CLI 加载 skill 时安全地提示用户输入。
```yaml
required_environment_variables:
- name: TENOR_API_KEY
prompt: Tenor API key
help: Get a key from https://developers.google.com/tenor
required_for: full functionality
```
用户可以跳过配置并继续加载 skill。Hermes 不会将原始密钥值暴露给模型。Gateway 和消息会话会显示本地配置指引,而不是在带内收集密钥。
:::tip 沙箱透传
加载 skill 时,已设置的 `required_environment_variables` 会**自动透传**到 `execute_code``terminal` 沙箱——包括 Docker 和 Modal 等远程后端。Skill 的脚本无需用户额外配置即可访问 `$TENOR_API_KEY`(或 Python 中的 `os.environ["TENOR_API_KEY"]`)。详见 [环境变量透传](/user-guide/security#environment-variable-passthrough)。
:::
旧版 `prerequisites.env_vars` 作为向后兼容的别名仍受支持。
### Config 配置项(config.yaml
Skill 可声明非密钥配置项,这些配置项存储在 `config.yaml``skills.config` 命名空间下。与环境变量(存储密钥)不同,config 配置项用于路径、偏好设置及其他非敏感值。
```yaml
metadata:
hermes:
config:
- key: myplugin.path
description: Path to the plugin data directory
default: "~/myplugin-data"
prompt: Plugin data directory path
- key: myplugin.domain
description: Domain the plugin operates on
default: ""
prompt: Plugin domain (e.g., AI/ML research)
```
每个条目支持:
- `key`(必需)——配置项的点路径(例如 `myplugin.path`
- `description`(必需)——说明该配置项的作用
- `default`(可选)——用户未配置时的默认值
- `prompt`(可选)——`hermes config migrate` 时显示的提示文本;若未设置则回退到 `description`
**工作原理:**
1. **存储:** 值写入 `config.yaml``skills.config.<key>` 下:
```yaml
skills:
config:
myplugin:
path: ~/my-data
```
2. **发现:** `hermes config migrate` 扫描所有已启用的 skill,找出未配置的项并提示用户。配置项也会在 `hermes config show` 的"Skill Settings"部分显示。
3. **运行时注入:** Skill 加载时,其 config 值会被解析并追加到 skill 消息中:
```
[Skill config (from ~/.hermes/config.yaml):
myplugin.path = /home/user/my-data
]
```
Agent 无需自行读取 `config.yaml` 即可看到已配置的值。
4. **手动配置:** 用户也可直接设置值:
```bash
hermes config set skills.config.myplugin.path ~/my-data
```
:::tip 如何选择
对 API key、token 及其他**密钥**使用 `required_environment_variables`(存储在 `~/.hermes/.env`,不向模型展示)。对**路径、偏好设置及非敏感配置**使用 `config`(存储在 `config.yaml`,在 config show 中可见)。
:::
### 凭证文件要求(OAuth token 等)
使用 OAuth 或基于文件的凭证的 skill 可声明需要挂载到远程沙箱的文件。这适用于以**文件**形式存储的凭证(而非环境变量)——通常是由配置脚本生成的 OAuth token 文件。
```yaml
required_credential_files:
- path: google_token.json
description: Google OAuth2 token (created by setup script)
- path: google_client_secret.json
description: Google OAuth2 client credentials
```
每个条目支持:
- `path`(必需)——相对于 `~/.hermes/` 的文件路径
- `description`(可选)——说明该文件的用途及创建方式
加载时,Hermes 会检查这些文件是否存在。缺少文件会触发 `setup_needed`。已存在的文件会自动:
- **挂载到 Docker** 容器中作为只读绑定挂载
- **同步到 Modal** 沙箱(在创建时及每次命令前同步,因此会话中途的 OAuth 也能正常工作)
- 在**本地**后端无需任何特殊处理即可使用
:::tip 如何选择
对简单的 API key 和 token(存储在 `~/.hermes/.env` 中的字符串)使用 `required_environment_variables`。对 OAuth token 文件、客户端密钥、服务账号 JSON、证书或任何以磁盘文件形式存在的凭证使用 `required_credential_files`。
:::
完整示例请参见 `skills/productivity/google-workspace/SKILL.md`,其中同时使用了两者。
## Skill 规范
### 无外部依赖
优先使用标准库 Python、curl 以及现有 Hermes tool`web_extract`、`terminal`、`read_file`)。若确实需要依赖项,请在 skill 中记录安装步骤。
### 渐进式披露
将最常见的工作流放在最前面。边缘情况和高级用法放在底部。这样可以降低常见任务的 token 消耗。
### 包含辅助脚本
对于 XML/JSON 解析或复杂逻辑,请在 `scripts/` 中包含辅助脚本——不要每次都期望 LLM 内联编写解析器。
### 以文档形式传递媒体(`[[as_document]]`
如果 skill 生成高分辨率截图、图表或任何有损预览压缩会造成损失的图片,请在响应中某处(通常是最后一行)输出字面指令 `[[as_document]]`。Gateway 会去除该指令,并将该响应中所有提取的媒体路径以可下载文件附件的形式传递,而非内联图片气泡。完整语义请参见 [Skill 输出与媒体传递](../user-guide/features/skills.md#skill-output-and-media-delivery)。
#### 在 SKILL.md 中引用内置脚本
Skill 加载时,激活消息会将 skill 目录的绝对路径以 `[Skill directory: /abs/path]` 的形式暴露,同时在 SKILL.md 正文中替换两个模板 token:
| Token | 替换为 |
|---|---|
| `${HERMES_SKILL_DIR}` | skill 目录的绝对路径 |
| `${HERMES_SESSION_ID}` | 当前会话 ID(若无会话则保留原样) |
因此,SKILL.md 可以直接告知 agent 运行内置脚本:
```markdown
To analyse the input, run:
node ${HERMES_SKILL_DIR}/scripts/analyse.js <input>
```
Agent 看到替换后的绝对路径,并使用 `terminal` tool 执行已就绪的命令——无需路径计算,无需额外的 `skill_view` 往返。可在 `config.yaml` 中设置 `skills.template_vars: false` 全局禁用替换。
#### 内联 shell 片段(需手动开启)
Skill 也可在 SKILL.md 正文中嵌入以 `` !`cmd` `` 形式编写的内联 shell 片段。启用后,每个片段的 stdout 会在 agent 读取前内联到消息中,从而让 skill 注入动态上下文:
```markdown
Current date: !`date -u +%Y-%m-%d`
Git branch: !`git -C ${HERMES_SKILL_DIR} rev-parse --abbrev-ref HEAD`
```
此功能**默认关闭**——SKILL.md 中的任何片段都会在未经审批的情况下在宿主机上运行,因此仅对你信任的 skill 来源启用:
```yaml
# config.yaml
skills:
inline_shell: true
inline_shell_timeout: 10 # 每个片段的超时秒数
```
片段以 skill 目录为工作目录运行,输出上限为 4000 个字符。失败(超时、非零退出)会显示为简短的 `[inline-shell error: ...]` 标记,而不会导致整个 skill 中断。
### 测试
运行 skill 并验证 agent 是否正确遵循指令:
```bash
hermes chat --toolsets skills -q "Use the X skill to do Y"
```
## Skill 应放在哪里?
内置 skill(位于 `skills/`)随每次 Hermes 安装一起发布,应对**大多数用户广泛有用**:
- 文档处理、网页研究、常见开发工作流、系统管理
- 被广泛人群定期使用
如果你的 skill 是官方的且有用,但并非所有人都需要(例如付费服务集成、重量级依赖),请放入 **`optional-skills/`**——它随仓库一起发布,可通过 `hermes skills browse` 发现(标记为"official"),并以内置信任级别安装。
如果你的 skill 是专业化的、社区贡献的或小众的,更适合放在 **Skills Hub**——将其上传到注册表并通过 `hermes skills install` 分享。
## 发布 Skill
### 发布到 Skills Hub
```bash
hermes skills publish skills/my-skill --to github --repo owner/repo
```
### 发布到自定义仓库
将你的仓库添加为 tap
```bash
hermes skills tap add owner/repo
```
用户随后可从你的仓库搜索并安装。
## 安全扫描
所有从 hub 安装的 skill 都会经过安全扫描器检查:
- 数据泄露模式
- Prompt 注入尝试
- 破坏性命令
- Shell 注入
信任级别:
- `builtin`——随 Hermes 一起发布(始终受信任)
- `official`——来自仓库中的 `optional-skills/`(内置信任,无第三方警告)
- `trusted`——来自 openai/skills、anthropics/skills、huggingface/skills
- `community`——非危险发现可通过 `--force` 覆盖;`dangerous` 判定仍会被阻止
Hermes 现在可以通过多种外部发现模型使用第三方 skill:
- 直接 GitHub 标识符(例如 `openai/skills/k8s`
- `skills.sh` 标识符(例如 `skills-sh/vercel-labs/json-render/json-render-react`
- 从 `/.well-known/skills/index.json` 提供的知名端点
如果你希望 skill 无需 GitHub 专属安装器即可被发现,除了在仓库或市场中发布外,还可以考虑通过知名端点提供服务。
@@ -0,0 +1,228 @@
---
sidebar_position: 11
title: "Cron 内部机制"
description: "Hermes 如何存储、调度、编辑、暂停、加载技能以及投递 cron 任务"
---
# Cron 内部机制
cron 子系统提供定时任务执行能力——从简单的单次延迟到带技能注入和跨平台投递的周期性 cron 表达式任务。
## 关键文件
| 文件 | 用途 |
|------|---------|
| `cron/jobs.py` | 任务模型、存储、对 `jobs.json` 的原子读写 |
| `cron/scheduler.py` | 调度器循环——到期任务检测、执行、重复计数跟踪 |
| `tools/cronjob_tools.py` | 面向模型的 `cronjob` 工具注册与处理器 |
| `gateway/run.py` | Gateway 集成——在长运行循环中触发 cron tick |
| `hermes_cli/cron.py` | CLI `hermes cron` 子命令 |
## 调度模型
支持四种调度格式:
| 格式 | 示例 | 行为 |
|--------|---------|----------|
| **相对延迟** | `30m``2h``1d` | 单次触发,在指定时长后执行 |
| **间隔** | `every 2h``every 30m` | 周期触发,按固定间隔执行 |
| **Cron 表达式** | `0 9 * * *` | 标准 5 字段 cron 语法(分钟、小时、日、月、星期) |
| **ISO 时间戳** | `2025-01-15T09:00:00` | 单次触发,在精确时间点执行 |
面向模型的接口是单个 `cronjob` 工具,支持以下操作:`create``list``update``pause``resume``run``remove`
## 任务存储
任务存储在 `~/.hermes/cron/jobs.json` 中,采用原子写入语义(先写入临时文件,再重命名)。每条任务记录包含:
```json
{
"id": "a1b2c3d4e5f6",
"name": "Daily briefing",
"prompt": "Summarize today's AI news and funding rounds",
"schedule": {
"kind": "cron",
"expr": "0 9 * * *",
"display": "0 9 * * *"
},
"skills": ["ai-funding-daily-report"],
"deliver": "telegram:-1001234567890",
"repeat": {
"times": null,
"completed": 42
},
"state": "scheduled",
"enabled": true,
"next_run_at": "2025-01-16T09:00:00Z",
"last_run_at": "2025-01-15T09:00:00Z",
"last_status": "ok",
"created_at": "2025-01-01T00:00:00Z",
"model": null,
"provider": null,
"script": null
}
```
### 任务生命周期状态
| 状态 | 含义 |
|-------|---------|
| `scheduled` | 活跃,将在下次计划时间触发 |
| `paused` | 已暂停——恢复前不会触发 |
| `completed` | 重复次数已耗尽,或单次任务已执行 |
| `running` | 正在执行(瞬态状态) |
### 向后兼容性
旧版任务可能使用单个 `skill` 字段而非 `skills` 数组。调度器在加载时会对此进行规范化——单个 `skill` 会被提升为 `skills: [skill]`
## 调度器运行时
### Tick 周期
调度器按周期性 tick 运行(默认:每 60 秒):
```text
tick()
1. 获取调度器锁(防止 tick 重叠)
2. 从 jobs.json 加载所有任务
3. 筛选到期任务(next_run <= now 且 state == "scheduled"
4. 对每个到期任务:
a. 将状态设为 "running"
b. 创建全新的 AIAgent 会话(无对话历史)
c. 按顺序加载附加技能(以用户消息形式注入)
d. 通过 agent 执行任务 prompt(提示词)
e. 将响应投递到配置的目标
f. 更新 run_count,计算下次运行时间
g. 若重复次数耗尽 → state = "completed"
h. 否则 → state = "scheduled"
5. 将更新后的任务写回 jobs.json
6. 释放调度器锁
```
### Gateway 集成
在 gateway 模式下,调度器运行在专用后台线程中(`gateway/run.py` 中的 `_start_cron_ticker`),每 60 秒调用一次 `scheduler.tick()`,与消息处理并行运行。
在 CLI 模式下,cron 任务仅在运行 `hermes cron` 命令或活跃 CLI 会话期间触发。
### 全新会话隔离
每个 cron 任务在完全全新的 agent 会话中运行:
- 无前次运行的对话历史
- 无前次 cron 执行的记忆(除非已持久化到内存/文件)
- prompt 必须自包含——cron 任务无法提出澄清性问题
- `cronjob` 工具集已禁用(递归防护)
## 技能支持的任务
cron 任务可通过 `skills` 字段附加一个或多个技能。执行时:
1. 按指定顺序加载技能
2. 每个技能的 SKILL.md 内容作为上下文注入
3. 任务的 prompt 作为任务指令追加
4. Agent 处理技能上下文与 prompt 的组合内容
这使得可复用、经过测试的工作流无需将完整指令粘贴到 cron prompt 中。例如:
```
创建每日融资报告 → 附加 "ai-funding-daily-report" 技能
```
### 脚本支持的任务
任务还可通过 `script` 字段附加 Python 脚本。该脚本在每次 agent 轮次*之前*运行,其 stdout 作为上下文注入到 prompt 中。这支持数据采集和变更检测模式:
```python
# ~/.hermes/scripts/check_competitors.py
import requests, json
# 获取竞争对手发布说明,与上次运行结果进行差异比对
# 将摘要打印到 stdout——agent 进行分析并报告
```
脚本超时默认为 120 秒。`_get_script_timeout()` 通过三层链路解析限制:
1. **模块级覆盖**`_SCRIPT_TIMEOUT`(用于测试/monkeypatching)。仅在与默认值不同时使用。
2. **环境变量**`HERMES_CRON_SCRIPT_TIMEOUT`
3. **配置**`config.yaml` 中的 `cron.script_timeout_seconds`(通过 `load_config()` 读取)
4. **默认值** — 120 秒
### Provider 恢复
`run_job()` 将用户配置的备用 provider 和凭证池传入 `AIAgent` 实例:
- **备用 provider** — 从 `config.yaml` 读取 `fallback_providers`(列表)或 `fallback_model`(旧版字典),与 gateway 的 `_load_fallback_model()` 模式一致。以 `fallback_model=` 形式传入 `AIAgent.__init__`,后者将两种格式规范化为备用链。
- **凭证池** — 通过 `agent.credential_pool` 中的 `load_pool(provider)` 使用解析后的运行时 provider 名称加载。仅在池中有凭证时传入(`pool.has_credentials()`)。在遭遇 429/限速错误时启用同 provider 的密钥轮换。
这与 gateway 的行为保持一致——否则 cron agent 在遭遇限速时将直接失败而不尝试恢复。
## 投递模型
Cron 任务结果可投递到任何受支持的平台:
| 目标 | 语法 | 示例 |
|--------|--------|---------|
| 来源聊天 | `origin` | 投递到创建该任务的聊天 |
| 本地文件 | `local` | 保存到 `~/.hermes/cron/output/` |
| Telegram | `telegram``telegram:<chat_id>` | `telegram:-1001234567890` |
| Discord | `discord``discord:#channel` | `discord:#engineering` |
| Slack | `slack` | 投递到 Slack 主频道 |
| WhatsApp | `whatsapp` | 投递到 WhatsApp 主会话 |
| Signal | `signal` | 投递到 Signal |
| Matrix | `matrix` | 投递到 Matrix 主房间 |
| Mattermost | `mattermost` | 投递到 Mattermost 主频道 |
| Email | `email` | 通过邮件投递 |
| SMS | `sms` | 通过短信投递 |
| Home Assistant | `homeassistant` | 投递到 HA 对话 |
| DingTalk | `dingtalk` | 投递到钉钉 |
| Feishu | `feishu` | 投递到飞书 |
| WeCom | `wecom` | 投递到企业微信 |
| Weixin | `weixin` | 投递到微信(WeChat |
| BlueBubbles | `bluebubbles` | 通过 BlueBubbles 投递到 iMessage |
| QQ Bot | `qqbot` | 通过官方 API v2 投递到 QQ(腾讯) |
对于 Telegram 话题,使用格式 `telegram:<chat_id>:<thread_id>`(例如 `telegram:-1001234567890:17585`)。
### 响应包装
默认情况下(`cron.wrap_response: true`),cron 投递内容会被包装:
- 头部标识 cron 任务名称和任务内容
- 尾部说明 agent 无法在对话中看到已投递的消息
cron 响应中的 `[SILENT]` 前缀会完全抑制投递——适用于只需写入文件或执行副作用的任务。
### 会话隔离
Cron 投递**不会**镜像到 gateway 会话的对话历史中。它们仅存在于 cron 任务自身的会话中。这可防止目标聊天对话中出现消息交替违规。
## 递归防护
Cron 运行的会话已禁用 `cronjob` 工具集。这可防止:
- 定时任务创建新的 cron 任务
- 可能导致 token 用量爆炸的递归调度
- 在任务内部意外修改任务调度
## 锁机制
调度器使用跨进程文件锁(Unix 上的 `fcntl.flock`Windows 上的 `msvcrt.locking`)防止重叠的 tick 对同一批到期任务执行两次——即使在 gateway 的进程内 ticker 与独立的 `hermes cron` / 手动 `tick()` 调用之间也如此。若无法获取锁,`tick()` 立即返回 0。
## CLI 接口
`hermes cron` CLI 提供直接的任务管理功能:
```bash
hermes cron list # 显示所有任务
hermes cron create # 交互式创建任务(别名:add
hermes cron edit <job_id> # 编辑任务配置
hermes cron pause <job_id> # 暂停运行中的任务
hermes cron resume <job_id> # 恢复已暂停的任务
hermes cron run <job_id> # 触发立即执行
hermes cron remove <job_id> # 删除任务
```
## 相关文档
- [Cron 功能指南](/user-guide/features/cron)
- [Gateway 内部机制](./gateway-internals.md)
- [Agent 循环内部机制](./agent-loop.md)
@@ -0,0 +1,192 @@
---
sidebar_position: 8
title: "扩展 CLI"
description: "构建包装 CLI,通过自定义 widget、快捷键和布局变更来扩展 Hermes TUI"
---
# 扩展 CLI
Hermes 在 `HermesCLI` 上暴露了受保护的扩展 hook(钩子),使包装 CLI 可以添加 widget、快捷键和布局自定义,而无需覆盖超过 1000 行的 `run()` 方法。这样可以让你的扩展与内部变更解耦。
## 扩展点
共有五个扩展接缝可用:
| Hook | 用途 | 何时覆盖 |
|------|---------|------------------|
| `_get_extra_tui_widgets()` | 向布局注入 widget | 需要持久 UI 元素(面板、状态栏、迷你播放器)时 |
| `_register_extra_tui_keybindings(kb, *, input_area)` | 添加键盘快捷键 | 需要热键(切换面板、传输控制、模态快捷键)时 |
| `_build_tui_layout_children(**widgets)` | 完全控制 widget 排序 | 需要重新排序或包装现有 widget 时(少见) |
| `process_command()` | 添加自定义斜杠命令 | 需要处理 `/mycommand` 时(已有 hook |
| `_build_tui_style_dict()` | 自定义 prompt_toolkit 样式 | 需要自定义颜色或样式时(已有 hook) |
前三个是新增的受保护 hook,后两个已存在。
## 快速开始:包装 CLI
```python
#!/usr/bin/env python3
"""my_cli.py — Example wrapper CLI that extends Hermes."""
from cli import HermesCLI
from prompt_toolkit.layout import FormattedTextControl, Window
from prompt_toolkit.filters import Condition
class MyCLI(HermesCLI):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._panel_visible = False
def _get_extra_tui_widgets(self):
"""Add a toggleable info panel above the status bar."""
cli_ref = self
return [
Window(
FormattedTextControl(lambda: "📊 My custom panel content"),
height=1,
filter=Condition(lambda: cli_ref._panel_visible),
),
]
def _register_extra_tui_keybindings(self, kb, *, input_area):
"""F2 toggles the custom panel."""
cli_ref = self
@kb.add("f2")
def _toggle_panel(event):
cli_ref._panel_visible = not cli_ref._panel_visible
def process_command(self, cmd: str) -> bool:
"""Add a /panel slash command."""
if cmd.strip().lower() == "/panel":
self._panel_visible = not self._panel_visible
state = "visible" if self._panel_visible else "hidden"
print(f"Panel is now {state}")
return True
return super().process_command(cmd)
if __name__ == "__main__":
cli = MyCLI()
cli.run()
```
运行:
```bash
cd ~/.hermes/hermes-agent
source .venv/bin/activate
python my_cli.py
```
## Hook 参考
### `_get_extra_tui_widgets()`
返回要插入 TUI 布局的 prompt_toolkit widget 列表。Widget 出现在**间隔区与状态栏之间**——位于输入区上方、主输出区下方。
```python
def _get_extra_tui_widgets(self) -> list:
return [] # default: no extra widgets
```
每个 widget 应为 prompt_toolkit 容器(如 `Window``ConditionalContainer``HSplit`)。使用 `ConditionalContainer``filter=Condition(...)` 可使 widget 支持切换显示。
```python
from prompt_toolkit.layout import ConditionalContainer, Window, FormattedTextControl
from prompt_toolkit.filters import Condition
def _get_extra_tui_widgets(self):
return [
ConditionalContainer(
Window(FormattedTextControl("Status: connected"), height=1),
filter=Condition(lambda: self._show_status),
),
]
```
### `_register_extra_tui_keybindings(kb, *, input_area)`
在 Hermes 注册自身快捷键之后、布局构建之前调用。将你的快捷键添加到 `kb`
```python
def _register_extra_tui_keybindings(self, kb, *, input_area):
pass # default: no extra keybindings
```
参数:
- **`kb`** — prompt_toolkit 应用的 `KeyBindings` 实例
- **`input_area`** — 主 `TextArea` widget,用于读取或操作用户输入
```python
def _register_extra_tui_keybindings(self, kb, *, input_area):
cli_ref = self
@kb.add("f3")
def _clear_input(event):
input_area.text = ""
@kb.add("f4")
def _insert_template(event):
input_area.text = "/search "
```
**避免与内置快捷键冲突**`Enter`(提交)、`Escape Enter`(换行)、`Ctrl-C`(中断)、`Ctrl-D`(退出)、`Tab`(接受自动建议)。F2 及以上的功能键和 Ctrl 组合键通常是安全的。
### `_build_tui_layout_children(**widgets)`
仅在需要完全控制 widget 排序时才覆盖此方法。大多数扩展应使用 `_get_extra_tui_widgets()` 代替。
```python
def _build_tui_layout_children(self, *, sudo_widget, secret_widget,
approval_widget, clarify_widget, model_picker_widget=None,
spinner_widget=None, spacer, status_bar, input_rule_top,
image_bar, input_area, input_rule_bot, voice_status_bar,
completions_menu) -> list:
```
默认实现返回(值为 `None` 的 widget 会被过滤掉):
```python
[
Window(height=0), # anchor
sudo_widget, # sudo password prompt (conditional)
secret_widget, # secret input prompt (conditional)
approval_widget, # dangerous command approval (conditional)
clarify_widget, # clarify question UI (conditional)
model_picker_widget, # model picker overlay (conditional)
spinner_widget, # thinking spinner (conditional)
spacer, # fills remaining vertical space
*self._get_extra_tui_widgets(), # YOUR WIDGETS GO HERE
status_bar, # model/token/context status line
input_rule_top, # ─── border above input
image_bar, # attached images indicator
input_area, # user text input
input_rule_bot, # ─── border below input
voice_status_bar, # voice mode status (conditional)
completions_menu, # autocomplete dropdown
]
```
## 布局示意图
默认布局从上到下:
1. **输出区** — 滚动的对话历史
2. **间隔区**
3. **额外 widget** — 来自 `_get_extra_tui_widgets()`
4. **状态栏** — 模型、上下文占比、已用时间
5. **图片栏** — 已附加图片数量
6. **输入区** — 用户 prompt(提示词)
7. **语音状态** — 录音指示器
8. **补全菜单** — 自动补全建议
## 使用技巧
- **状态变更后刷新显示**:调用 `self._invalidate()` 触发 prompt_toolkit 重绘。
- **访问 agent 状态**`self.agent``self.model``self.conversation_history` 均可直接使用。
- **自定义样式**:覆盖 `_build_tui_style_dict()` 并为自定义样式类添加条目。
- **斜杠命令**:覆盖 `process_command()`,处理自己的命令,其余一律调用 `super().process_command(cmd)`
- **不要覆盖 `run()`**,除非绝对必要——扩展 hook 的存在正是为了避免这种耦合。
@@ -0,0 +1,262 @@
---
sidebar_position: 7
title: "Gateway 内部机制"
description: "消息 gateway 如何启动、授权用户、路由会话以及投递消息"
---
# Gateway 内部机制
消息 gateway 是一个长期运行的进程,通过统一架构将 Hermes 连接到 20 余个外部消息平台。
## 关键文件
| 文件 | 用途 |
|------|---------|
| `gateway/run.py` | `GatewayRunner` — 主循环、斜杠命令、消息分发(大文件;请查看 git 获取当前行数) |
| `gateway/session.py` | `SessionStore` — 会话持久化与会话键构造 |
| `gateway/delivery.py` | 向目标平台/频道投递出站消息 |
| `gateway/pairing.py` | 用于用户授权的 DM 配对流程 |
| `gateway/channel_directory.py` | 将聊天 ID 映射为可读名称,用于 cron 投递 |
| `gateway/hooks.py` | Hook(钩子)发现、加载与生命周期事件分发 |
| `gateway/mirror.py` | 为 `send_message` 提供跨会话消息镜像 |
| `gateway/status.py` | 面向 profile 范围的 gateway 实例的 token 锁管理 |
| `gateway/builtin_hooks/` | 始终注册的 hook 扩展点(当前未内置任何 hook) |
| `gateway/platforms/` | 平台适配器(每个消息平台一个) |
## 架构概览
```text
┌─────────────────────────────────────────────────┐
│ GatewayRunner │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Telegram │ │ Discord │ │ Slack │ │
│ │ Adapter │ │ Adapter │ │ Adapter │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ └─────────────┼─────────────┘ │
│ ▼ │
│ _handle_message() │
│ │ │
│ ┌───────────┼───────────┐ │
│ ▼ ▼ ▼ │
│ Slash command AIAgent Queue/BG │
│ dispatch creation sessions │
│ │ │
│ ▼ │
│ SessionStore │
│ (SQLite persistence) │
└───────┴─────────────┴─────────────┴─────────────┘
```
## 消息流程
当消息从任意平台到达时:
1. **平台适配器**接收原始事件,将其规范化为 `MessageEvent`
2. **基础适配器**检查活跃会话守卫:
- 若该会话的 agent 正在运行 → 将消息加入队列,设置中断事件
- 若为 `/approve``/deny``/stop` → 绕过守卫(内联分发)
3. **GatewayRunner._handle_message()** 接收事件:
- 通过 `_session_key_for_source()` 解析会话键(格式:`agent:main:{platform}:{chat_type}:{chat_id}`
- 检查授权(见下方授权章节)
- 检查是否为斜杠命令 → 分发至命令处理器
- 检查 agent 是否已在运行 → 拦截 `/stop``/status` 等命令
- 否则 → 创建 `AIAgent` 实例并运行对话
4. **响应**通过平台适配器回传
### 会话键格式
会话键编码了完整的路由上下文:
```
agent:main:{platform}:{chat_type}:{chat_id}
```
示例:`agent:main:telegram:private:123456789`
支持线程的平台(Telegram 论坛话题、Discord 线程、Slack 线程)可能在 chat_id 部分包含线程 ID。**切勿手动构造会话键** — 请始终使用 `gateway/session.py` 中的 `build_session_key()`
### 两级消息守卫
当 agent 正在运行时,传入消息会依次经过两级守卫:
1. **第一级 — 基础适配器**`gateway/platforms/base.py`):检查 `_active_sessions`。若会话处于活跃状态,将消息加入 `_pending_messages` 队列并设置中断事件。此级在消息到达 gateway runner *之前*进行拦截。
2. **第二级 — Gateway runner**`gateway/run.py`):检查 `_running_agents`。拦截特定命令(`/stop``/new``/queue``/status``/approve``/deny`)并进行相应路由。其余所有消息触发 `running_agent.interrupt()`
必须在 agent 被阻塞时到达 runner 的命令(如 `/approve`)通过 `await self._message_handler(event)` **内联**分发 — 绕过后台任务系统以避免竞态条件。
## 授权
Gateway 使用多层授权检查,按顺序评估:
1. **平台级全量放行标志**(如 `TELEGRAM_ALLOW_ALL_USERS`)— 若设置,该平台所有用户均被授权
2. **平台白名单**(如 `TELEGRAM_ALLOWED_USERS`)— 逗号分隔的用户 ID
3. **DM 配对** — 已认证用户可通过配对码为新用户授权
4. **全局放行标志**`GATEWAY_ALLOW_ALL_USERS`)— 若设置,所有平台的所有用户均被授权
5. **默认:拒绝** — 未授权用户被拒绝
### DM 配对流程
```text
Admin: /pair
Gateway: "Pairing code: ABC123. Share with the user."
New user: ABC123
Gateway: "Paired! You're now authorized."
```
配对状态持久化于 `gateway/pairing.py`,重启后仍然有效。
## 斜杠命令分发
Gateway 中所有斜杠命令均经过相同的解析流程:
1. `hermes_cli/commands.py` 中的 `resolve_command()` 将输入映射为规范名称(处理别名、前缀匹配)
2. 规范名称与 `GATEWAY_KNOWN_COMMANDS` 进行比对
3. `_handle_message()` 中的处理器根据规范名称进行分发
4. 部分命令受配置门控(`CommandDef` 上的 `gateway_config_gate`
### 运行中 Agent 守卫
在 agent 处理消息期间不得执行的命令会被提前拒绝:
```python
if _quick_key in self._running_agents:
if canonical == "model":
return "⏳ Agent is running — wait for it to finish or /stop first."
```
绕过命令(`/stop``/new``/approve``/deny``/queue``/status`)具有特殊处理逻辑。
## 配置来源
Gateway 从多个来源读取配置:
| 来源 | 提供内容 |
|--------|-----------------|
| `~/.hermes/.env` | API 密钥、bot token、平台凭据 |
| `~/.hermes/config.yaml` | 模型设置、工具配置、显示选项 |
| 环境变量 | 覆盖上述任意配置 |
与 CLI(使用带硬编码默认值的 `load_cli_config()`)不同,gateway 通过 YAML 加载器直接读取 `config.yaml`。这意味着存在于 CLI 默认值字典但不在用户配置文件中的配置键,在 CLI 和 gateway 之间可能表现不同。
## 平台适配器
每个消息平台在 `gateway/platforms/` 下均有对应适配器:
```text
gateway/platforms/
├── base.py # BaseAdapter — 所有平台的共享逻辑
├── telegram.py # Telegram Bot API(长轮询或 webhook
├── discord.py # Discord bot(通过 discord.py
├── slack.py # Slack Socket Mode
├── whatsapp.py # WhatsApp Business Cloud API
├── signal.py # Signal(通过 signal-cli REST API
├── matrix.py # Matrix(通过 mautrix,可选 E2EE
├── mattermost.py # Mattermost WebSocket API
├── email.py # 电子邮件(通过 IMAP/SMTP
├── sms.py # 短信(通过 Twilio
├── dingtalk.py # 钉钉 WebSocket
├── feishu.py # 飞书/Lark WebSocket 或 webhook
├── wecom.py # 企业微信(WeCom)回调
├── weixin.py # 微信(个人版,通过 iLink Bot API
├── bluebubbles.py # Apple iMessage(通过 BlueBubbles macOS 服务端)
├── qqbot/ # QQ Bot(腾讯 QQ,通过官方 API v2,子包:adapter.py、crypto.py、keyboards.py 等)
├── yuanbao.py # 元宝(腾讯)私信/群组适配器
├── feishu_comment.py # 飞书文档/云盘评论回复处理器
├── msgraph_webhook.py # Microsoft Graph 变更通知 webhookTeams、Outlook 等)
├── webhook.py # 入站/出站 webhook 适配器
├── api_server.py # REST API 服务器适配器
└── homeassistant.py # Home Assistant 对话集成
```
适配器实现统一接口:
- `connect()` / `disconnect()` — 生命周期管理
- `send_message()` — 出站消息投递
- `on_message()` — 入站消息规范化 → `MessageEvent`
### Token 锁
使用唯一凭据连接的适配器在 `connect()` 中调用 `acquire_scoped_lock()`,在 `disconnect()` 中调用 `release_scoped_lock()`。这可防止两个 profile 同时使用同一 bot token。
## 投递路径
出站投递(`gateway/delivery.py`)处理以下场景:
- **直接回复** — 将响应发回原始聊天
- **主频道投递** — 将 cron 任务输出和后台结果路由至已配置的主频道
- **显式目标投递** — `send_message` 工具指定 `telegram:-1001234567890`,或通过 [`hermes send` CLI](/guides/pipe-script-output) 封装同一工具供 shell 脚本使用
- **跨平台投递** — 投递至与原始消息不同的平台
Cron 任务投递**不会**镜像到 gateway 会话历史中 — 它们仅存在于各自的 cron 会话中。这是有意为之的设计选择,以避免消息交替违规。
## Hooks
Gateway hook 是响应生命周期事件的 Python 模块。
### Gateway Hook 事件
| 事件 | 触发时机 |
|-------|-----------|
| `gateway:startup` | Gateway 进程启动时 |
| `session:start` | 新对话会话开始时 |
| `session:end` | 会话完成或超时时 |
| `session:reset` | 用户通过 `/new` 重置会话时 |
| `agent:start` | Agent 开始处理消息时 |
| `agent:step` | Agent 完成一次工具调用迭代时 |
| `agent:end` | Agent 完成并返回响应时 |
| `command:*` | 任意斜杠命令被执行时 |
Hook 从 `gateway/builtin_hooks/`(扩展点 — 当前发行版中为空;`_register_builtin_hooks()` 是一个空操作存根)和 `~/.hermes/hooks/`(用户安装)中发现。每个 hook 是一个包含 `HOOK.yaml` 清单和 `handler.py` 的目录。
## 内存提供者集成
当内存提供者插件(如 Honcho)启用时:
1. Gateway 为每条消息创建一个带会话 ID 的 `AIAgent`
2. `MemoryManager` 使用会话上下文初始化提供者
3. 提供者工具(如 `honcho_profile``viking_search`)通过以下路径路由:
```text
AIAgent._invoke_tool()
→ self._memory_manager.handle_tool_call(name, args)
→ provider.handle_tool_call(name, args)
```
4. 会话结束/重置时,`on_session_end()` 触发以进行清理和最终数据刷写
### 内存刷写生命周期
当会话被重置、恢复或过期时:
1. 内置内存刷写至磁盘
2. 内存提供者的 `on_session_end()` hook 触发
3. 临时 `AIAgent` 运行仅含内存的对话轮次
4. 上下文随后被丢弃或归档
## 后台维护
Gateway 在处理消息的同时运行周期性维护任务:
- **Cron 计时** — 检查任务计划并触发到期任务
- **会话过期** — 超时后清理废弃会话
- **内存刷写** — 在会话过期前主动刷写内存
- **缓存刷新** — 刷新模型列表和提供者状态
## 进程管理
Gateway 作为长期运行进程运行,管理方式如下:
- `hermes gateway start` / `hermes gateway stop` — 手动控制
- `systemctl`Linux)或 `launchctl`macOS)— 服务管理
- PID 文件位于 `~/.hermes/gateway.pid` — 面向 profile 的进程追踪
**Profile 范围 vs 全局**`start_gateway()` 使用 profile 范围的 PID 文件。`hermes gateway stop` 仅停止当前 profile 的 gateway。`hermes gateway stop --all` 使用全局 `ps aux` 扫描来终止所有 gateway 进程(用于更新时)。
## 相关文档
- [会话存储](./session-storage.md)
- [Cron 内部机制](./cron-internals.md)
- [ACP 内部机制](./acp-internals.md)
- [Agent 循环内部机制](./agent-loop.md)
- [消息 Gateway(用户指南)](/user-guide/messaging)
@@ -0,0 +1,288 @@
---
sidebar_position: 11
title: "图像生成 Provider 插件"
description: "如何为 Hermes Agent 构建图像生成后端插件"
---
# 构建图像生成 Provider 插件
图像生成 provider 插件注册一个后端,用于处理所有 `image_generate` 工具调用——DALL·E、gpt-image、Grok、Flux、Imagen、Stable Diffusion、fal、Replicate、本地 ComfyUI 装置,任何后端均可。内置 providerOpenAI、OpenAI-Codex、xAI)均以插件形式提供。你可以通过在 `plugins/image_gen/<name>/` 目录下放置一个目录来添加新的 provider,或覆盖内置 provider。
:::tip
图像生成是 Hermes 支持的多种**后端插件**之一。其他插件(各有更专用的 ABC)包括:[Memory Provider 插件](/developer-guide/memory-provider-plugin)、[Context Engine 插件](/developer-guide/context-engine-plugin) 和 [Model Provider 插件](/developer-guide/model-provider-plugin)。通用工具/hook/CLI 插件请参阅 [构建 Hermes 插件](/guides/build-a-hermes-plugin)。
:::
## 发现机制
Hermes 在三个位置扫描图像生成后端:
1. **内置**`<repo>/plugins/image_gen/<name>/`(以 `kind: backend` 自动加载,始终可用)
2. **用户**`~/.hermes/plugins/image_gen/<name>/`(通过 `plugins.enabled` 选择启用)
3. **Pip** — 声明了 `hermes_agent.plugins` 入口点的包
每个插件的 `register(ctx)` 函数调用 `ctx.register_image_gen_provider(...)` — 将其注册到 `agent/image_gen_registry.py` 中的注册表。活跃 provider 由 `config.yaml` 中的 `image_gen.provider` 指定;`hermes tools` 会引导用户完成选择。
`image_generate` 工具包装器向注册表请求活跃 provider 并分发调用。若未注册任何 provider,工具会显示一条有用的错误信息,指引用户使用 `hermes tools`
## 目录结构
```
plugins/image_gen/my-backend/
├── __init__.py # ImageGenProvider 子类 + register()
└── plugin.yaml # 包含 kind: backend 的清单文件
```
内置插件到此即完整。位于 `~/.hermes/plugins/image_gen/<name>/` 的用户插件需要在 `config.yaml``plugins.enabled` 中添加(或运行 `hermes plugins enable <name>`)。
## ImageGenProvider ABC
继承 `agent.image_gen_provider.ImageGenProvider`。唯一必须实现的成员是 `name` 属性和 `generate()` 方法——其他所有成员均有合理的默认值:
```python
# plugins/image_gen/my-backend/__init__.py
from typing import Any, Dict, List, Optional
import os
from agent.image_gen_provider import (
DEFAULT_ASPECT_RATIO,
ImageGenProvider,
error_response,
resolve_aspect_ratio,
save_b64_image,
success_response,
)
class MyBackendImageGenProvider(ImageGenProvider):
@property
def name(self) -> str:
# Stable id used in image_gen.provider config. Lowercase, no spaces.
return "my-backend"
@property
def display_name(self) -> str:
# Human label shown in `hermes tools`. Defaults to name.title() if omitted.
return "My Backend"
def is_available(self) -> bool:
# Return False if credentials or deps are missing.
# The tool's availability gate calls this before dispatch.
if not os.environ.get("MY_BACKEND_API_KEY"):
return False
try:
import my_backend_sdk # noqa: F401
except ImportError:
return False
return True
def list_models(self) -> List[Dict[str, Any]]:
# Catalog shown in `hermes tools` model picker.
return [
{
"id": "my-model-fast",
"display": "My Model (Fast)",
"speed": "~5s",
"strengths": "Quick iteration",
"price": "$0.01/image",
},
{
"id": "my-model-hq",
"display": "My Model (HQ)",
"speed": "~30s",
"strengths": "Highest fidelity",
"price": "$0.04/image",
},
]
def default_model(self) -> Optional[str]:
return "my-model-fast"
def get_setup_schema(self) -> Dict[str, Any]:
# Metadata for the `hermes tools` picker — keys to prompt for at setup.
return {
"name": "My Backend",
"badge": "paid", # optional; shown as a short tag in the picker
"tag": "One-line description shown under the name",
"env_vars": [
{
"key": "MY_BACKEND_API_KEY",
"prompt": "My Backend API key",
"url": "https://my-backend.example.com/api-keys",
},
],
}
def generate(
self,
prompt: str,
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
**kwargs: Any,
) -> Dict[str, Any]:
prompt = (prompt or "").strip()
aspect_ratio = resolve_aspect_ratio(aspect_ratio)
if not prompt:
return error_response(
error="Prompt is required",
error_type="invalid_input",
provider=self.name,
prompt="",
aspect_ratio=aspect_ratio,
)
# Model selection precedence: env var → config → default. The helper
# _resolve_model() in the built-in openai plugin is a good reference.
model_id = kwargs.get("model") or self.default_model() or "my-model-fast"
try:
import my_backend_sdk
client = my_backend_sdk.Client(api_key=os.environ["MY_BACKEND_API_KEY"])
result = client.generate(
prompt=prompt,
model=model_id,
aspect_ratio=aspect_ratio,
)
# Two shapes supported:
# - URL string: return it as `image`
# - base64 data: save under $HERMES_HOME/cache/images/ via save_b64_image()
if result.get("image_b64"):
path = save_b64_image(
result["image_b64"],
prefix=self.name,
extension="png",
)
image = str(path)
else:
image = result["image_url"]
return success_response(
image=image,
model=model_id,
prompt=prompt,
aspect_ratio=aspect_ratio,
provider=self.name,
)
except Exception as exc:
return error_response(
error=str(exc),
error_type=type(exc).__name__,
provider=self.name,
model=model_id,
prompt=prompt,
aspect_ratio=aspect_ratio,
)
def register(ctx) -> None:
"""Plugin entry point — called once at load time."""
ctx.register_image_gen_provider(MyBackendImageGenProvider())
```
## plugin.yaml
```yaml
name: my-backend
version: 1.0.0
description: My image backend — text-to-image via My Backend SDK
author: Your Name
kind: backend
requires_env:
- MY_BACKEND_API_KEY
```
`kind: backend` 决定插件被路由到图像生成注册路径。`requires_env``hermes plugins install` 期间会提示用户输入。
## ABC 参考
完整契约位于 `agent/image_gen_provider.py`。通常需要覆盖的方法:
| 成员 | 必须 | 默认值 | 用途 |
|---|---|---|---|
| `name` | ✅ | — | 在 `image_gen.provider` 配置中使用的稳定 id |
| `display_name` | — | `name.title()` | 在 `hermes tools` 中显示的标签 |
| `is_available()` | — | `True` | 缺少凭据/依赖时的拦截门控 |
| `list_models()` | — | `[]` | `hermes tools` 模型选择器的目录 |
| `default_model()` | — | `list_models()` 的第一项 | 未配置模型时的回退 |
| `get_setup_schema()` | — | 最小值 | 选择器元数据 + 环境变量提示 |
| `generate(prompt, aspect_ratio, **kwargs)` | ✅ | — | 实际调用 |
## 响应格式
`generate()` 必须返回通过 `success_response()``error_response()` 构建的字典。两者均位于 `agent/image_gen_provider.py`
**成功:**
```python
success_response(
image=<url-or-absolute-path>,
model=<model-id>,
prompt=<echoed-prompt>,
aspect_ratio="landscape" | "square" | "portrait",
provider=<your-provider-name>,
extra={...}, # optional backend-specific fields
)
```
**错误:**
```python
error_response(
error="human-readable message",
error_type="provider_error" | "invalid_input" | "<exception class name>",
provider=<your-provider-name>,
model=<model-id>,
prompt=<prompt>,
aspect_ratio=<resolved aspect>,
)
```
工具包装器将字典 JSON 序列化后传给 LLM。错误以工具结果的形式呈现;LLM 决定如何向用户解释。
## 处理 base64 与 URL 输出
部分后端返回图像 URL(fal、Replicate);其他后端返回 base64 载荷(OpenAI gpt-image-2)。对于 base64 情况,使用 `save_b64_image()` — 它将文件写入 `$HERMES_HOME/cache/images/<prefix>_<timestamp>_<uuid>.<ext>` 并返回绝对 `Path`。将该路径(转为 `str`)作为 `image=` 传入 `success_response()`。Gateway 投递(Telegram 图片气泡、Discord 附件)同时识别 URL 和绝对路径。
## 用户覆盖
`~/.hermes/plugins/image_gen/<name>/` 放置一个用户插件,使其 `name` 属性与某个内置插件相同,并通过 `hermes plugins enable <name>` 启用——注册表采用后写入优先策略,你的版本将替换内置版本。适用于将 `openai` 插件指向私有代理,或替换自定义模型目录等场景。
## 测试
```bash
export HERMES_HOME=/tmp/hermes-imggen-test
mkdir -p $HERMES_HOME/plugins/image_gen/my-backend
# …copy __init__.py + plugin.yaml into that dir…
export MY_BACKEND_API_KEY=your-test-key
hermes plugins enable my-backend
# Pick it as the active provider
echo "image_gen:" >> $HERMES_HOME/config.yaml
echo " provider: my-backend" >> $HERMES_HOME/config.yaml
# Exercise it
hermes -z "Generate an image of a corgi in a spacesuit"
```
或交互式操作:`hermes tools` → "Image Generation" → 选择 `my-backend` → 根据提示输入 API key。
## 参考实现
- **`plugins/image_gen/openai/__init__.py`** — gpt-image-2 以低/中/高三个档位作为三个虚拟模型 ID,共享同一 API 模型并使用不同的 `quality` 参数。适合参考单一后端下的分层模型设计 + config.yaml 优先级链。
- **`plugins/image_gen/xai/__init__.py`** — 通过 xAI 的 Grok Imagine。不同的响应结构(URL 输出,目录更简单)。
- **`plugins/image_gen/openai-codex/__init__.py`** — Codex 风格的 Responses API 变体,复用 OpenAI SDK 并使用不同的路由基础 URL。
## 通过 pip 分发
```toml
# pyproject.toml
[project.entry-points."hermes_agent.plugins"]
my-backend-imggen = "my_backend_imggen_package"
```
`my_backend_imggen_package` 必须暴露一个顶层 `register` 函数。完整配置请参阅通用插件指南中的 [通过 pip 分发](/guides/build-a-hermes-plugin#distribute-via-pip)。
## 相关页面
- [图像生成](/user-guide/features/image-generation) — 面向用户的功能文档
- [插件概览](/user-guide/features/plugins) — 所有插件类型一览
- [构建 Hermes 插件](/guides/build-a-hermes-plugin) — 通用工具/hook/斜杠命令指南
@@ -0,0 +1,258 @@
---
sidebar_position: 8
title: "Memory Provider 插件"
description: "如何为 Hermes Agent 构建 memory provider 插件"
---
# 构建 Memory Provider 插件
Memory provider 插件为 Hermes Agent 提供跨会话的持久化知识,超越内置的 MEMORY.md 和 USER.md。本指南介绍如何构建一个 memory provider 插件。
:::tip
Memory provider 是两种 **provider 插件**类型之一。另一种是 [Context Engine 插件](/developer-guide/context-engine-plugin),用于替换内置的上下文压缩器。两者遵循相同的模式:单选、配置驱动、通过 `hermes plugins` 管理。
:::
## 目录结构
每个 memory provider 位于 `plugins/memory/<name>/`
```
plugins/memory/my-provider/
├── __init__.py # MemoryProvider 实现 + register() 入口点
├── plugin.yaml # 元数据(name、description、hooks
└── README.md # 配置说明、配置参考、工具
```
## MemoryProvider 抽象基类
你的插件需要实现 `agent/memory_provider.py` 中的 `MemoryProvider` 抽象基类(ABC):
```python
from agent.memory_provider import MemoryProvider
class MyMemoryProvider(MemoryProvider):
@property
def name(self) -> str:
return "my-provider"
def is_available(self) -> bool:
"""检查此 provider 是否可以激活。禁止发起网络请求。"""
return bool(os.environ.get("MY_API_KEY"))
def initialize(self, session_id: str, **kwargs) -> None:
"""在 agent 启动时调用一次。
kwargs 始终包含:
hermes_home (str): 当前活跃的 HERMES_HOME 路径。用于存储数据。
"""
self._api_key = os.environ.get("MY_API_KEY", "")
self._session_id = session_id
# ... 实现其余方法
```
## 必须实现的方法
### 核心生命周期
| 方法 | 调用时机 | 是否必须实现? |
|--------|-----------|-----------------|
| `name`property | 始终 | **是** |
| `is_available()` | agent 初始化,激活前 | **是** — 禁止网络请求 |
| `initialize(session_id, **kwargs)` | agent 启动 | **是** |
| `get_tool_schemas()` | 初始化后,用于注入工具 | **是** |
| `handle_tool_call(name, args)` | agent 调用你的工具时 | **是**(如果有工具) |
### 配置
| 方法 | 用途 | 是否必须实现? |
|--------|---------|-----------------|
| `get_config_schema()` | 为 `hermes memory setup` 声明配置字段 | **是** |
| `save_config(values, hermes_home)` | 将非敏感配置写入原生位置 | **是**(除非仅使用环境变量) |
### 可选 Hook
| 方法 | 调用时机 | 使用场景 |
|--------|-----------|----------|
| `system_prompt_block()` | 系统 prompt 组装时 | 静态 provider 信息 |
| `prefetch(query)` | 每次 API 调用前 | 返回召回的上下文 |
| `queue_prefetch(query)` | 每轮对话结束后 | 为下一轮预热 |
| `sync_turn(user, assistant)` | 每轮对话完成后 | 持久化对话内容 |
| `on_session_end(messages)` | 对话结束时 | 最终提取/刷新 |
| `on_pre_compress(messages)` | 上下文压缩前 | 在丢弃前保存关键信息 |
| `on_memory_write(action, target, content)` | 内置 memory 写入时 | 同步到你的后端 |
| `shutdown()` | 进程退出时 | 清理连接 |
## 配置 Schema
`get_config_schema()` 返回一个字段描述符列表,供 `hermes memory setup` 使用:
```python
def get_config_schema(self):
return [
{
"key": "api_key",
"description": "My Provider API key",
"secret": True, # → 写入 .env
"required": True,
"env_var": "MY_API_KEY", # 显式指定环境变量名
"url": "https://my-provider.com/keys", # 获取密钥的地址
},
{
"key": "region",
"description": "Server region",
"default": "us-east",
"choices": ["us-east", "eu-west", "ap-south"],
},
{
"key": "project",
"description": "Project identifier",
"default": "hermes",
},
]
```
`secret: True` 且带有 `env_var` 的字段写入 `.env`。非敏感字段传递给 `save_config()`
:::tip 最简 Schema 与完整 Schema
`get_config_schema()` 中的每个字段都会在 `hermes memory setup` 期间提示用户输入。选项较多的 provider 应保持 schema 精简——只包含用户**必须**配置的字段(API key、必要凭证)。可选配置请在配置文件参考文档中说明(例如 `$HERMES_HOME/myprovider.json`),而不是在 setup 向导中逐一提示。这样既能保持 setup 流程简洁,又支持高级配置。可参考 Supermemory provider 的实现——它只提示输入 API key,其余选项均位于 `supermemory.json` 中。
:::
## 保存配置
```python
def save_config(self, values: dict, hermes_home: str) -> None:
"""将非敏感配置写入原生位置。"""
import json
from pathlib import Path
config_path = Path(hermes_home) / "my-provider.json"
config_path.write_text(json.dumps(values, indent=2))
```
对于仅使用环境变量的 provider,保留默认的空实现即可。
## 插件入口点
```python
def register(ctx) -> None:
"""由 memory 插件发现系统调用。"""
ctx.register_memory_provider(MyMemoryProvider())
```
## plugin.yaml
```yaml
name: my-provider
version: 1.0.0
description: "此 provider 功能的简短描述。"
hooks:
- on_session_end # 列出你实现的 hook
```
## 线程约定
**`sync_turn()` 必须是非阻塞的。** 如果你的后端存在延迟(API 调用、LLM 处理),请在守护线程中执行:
```python
def sync_turn(self, user_content, assistant_content):
def _sync():
try:
self._api.ingest(user_content, assistant_content)
except Exception as e:
logger.warning("Sync failed: %s", e)
if self._sync_thread and self._sync_thread.is_alive():
self._sync_thread.join(timeout=5.0)
self._sync_thread = threading.Thread(target=_sync, daemon=True)
self._sync_thread.start()
```
## Profile 隔离
所有存储路径**必须**使用 `initialize()` 中的 `hermes_home` kwarg,而不是硬编码的 `~/.hermes`
```python
# 正确 — 按 profile 隔离
from hermes_constants import get_hermes_home
data_dir = get_hermes_home() / "my-provider"
# 错误 — 所有 profile 共享
data_dir = Path("~/.hermes/my-provider").expanduser()
```
## 测试
完整的端到端测试模式(使用真实 SQLite provider)请参见 `tests/agent/test_memory_plugin_e2e.py`
```python
from agent.memory_manager import MemoryManager
mgr = MemoryManager()
mgr.add_provider(my_provider)
mgr.initialize_all(session_id="test-1", platform="cli")
# 测试工具路由
result = mgr.handle_tool_call("my_tool", {"action": "add", "content": "test"})
# 测试生命周期
mgr.sync_all("user msg", "assistant msg")
mgr.on_session_end([])
mgr.shutdown_all()
```
## 添加 CLI 命令
Memory provider 插件可以注册自己的 CLI 子命令树(例如 `hermes my-provider status``hermes my-provider config`)。这套系统基于约定发现,无需修改核心文件。
### 工作原理
1. 在插件目录中添加 `cli.py` 文件
2. 定义 `register_cli(subparser)` 函数来构建 argparse 树
3. memory 插件系统在启动时通过 `discover_plugin_cli_commands()` 自动发现
4. 你的命令以 `hermes <provider-name> <subcommand>` 的形式出现
**仅对活跃 provider 开放:** 你的 CLI 命令只在你的 provider 是配置中活跃的 `memory.provider` 时才会出现。如果用户尚未配置你的 provider,你的命令不会显示在 `hermes --help` 中。
### 示例
```python
# plugins/memory/my-provider/cli.py
def my_command(args):
"""由 argparse 分发的处理函数。"""
sub = getattr(args, "my_command", None)
if sub == "status":
print("Provider is active and connected.")
elif sub == "config":
print("Showing config...")
else:
print("Usage: hermes my-provider <status|config>")
def register_cli(subparser) -> None:
"""构建 hermes my-provider 的 argparse 树。
在 argparse 初始化时由 discover_plugin_cli_commands() 调用。
"""
subs = subparser.add_subparsers(dest="my_command")
subs.add_parser("status", help="Show provider status")
subs.add_parser("config", help="Show provider config")
subparser.set_defaults(func=my_command)
```
### 参考实现
完整示例请参见 `plugins/memory/honcho/cli.py`,包含 13 个子命令、跨 profile 管理(`--target-profile`)以及配置读写。
### 含 CLI 的目录结构
```
plugins/memory/my-provider/
├── __init__.py # MemoryProvider 实现 + register()
├── plugin.yaml # 元数据
├── cli.py # register_cli(subparser) — CLI 命令
└── README.md # 配置说明
```
## 单 Provider 规则
同一时间只能有**一个**外部 memory provider 处于活跃状态。如果用户尝试注册第二个,MemoryManager 会拒绝并发出警告。这可以防止工具 schema 膨胀和后端冲突。
@@ -0,0 +1,267 @@
---
sidebar_position: 10
title: "模型提供商插件"
description: "如何为 Hermes Agent 构建模型提供商(推理后端)插件"
---
# 构建模型提供商插件
模型提供商插件声明一个推理后端——兼容 OpenAI 的端点、Anthropic Messages 服务器、Codex 风格的 Responses API,或 Bedrock 原生接口——Hermes 可通过这些后端路由 `AIAgent` 调用。每个内置提供商(OpenRouter、Anthropic、GMI、DeepSeek、Nvidia……)都以此类插件形式提供。第三方可通过在 `$HERMES_HOME/plugins/model-providers/` 下放置一个目录来添加自己的提供商,无需对仓库做任何修改。
:::tip
模型提供商插件是**提供商插件**的第三种类型。其他两种分别是 [Memory Provider 插件](/developer-guide/memory-provider-plugin)(跨会话知识)和 [Context Engine 插件](/developer-guide/context-engine-plugin)(上下文压缩策略)。三者均遵循相同的"放入目录、声明 profile、无需编辑仓库"模式。
:::
## 发现机制
`providers/__init__.py._discover_providers()` 在任何代码首次调用 `get_provider_profile()``list_providers()` 时懒加载执行。发现顺序:
1. **内置插件**`<repo>/plugins/model-providers/<name>/` — 随 Hermes 一同发布
2. **用户插件**`$HERMES_HOME/plugins/model-providers/<name>/` — 放入任意目录;后续会话无需重启即可生效
3. **旧版单文件**`<repo>/providers/<name>.py` — 为树外可编辑安装提供向后兼容
**同名用户插件会覆盖内置插件**,因为 `register_provider()` 采用后写者优先策略。放入 `$HERMES_HOME/plugins/model-providers/gmi/` 目录即可替换内置 GMI profile,无需修改仓库。
## 目录结构
```
plugins/model-providers/my-provider/
├── __init__.py # 在模块级别调用 register_provider(profile)
├── plugin.yaml # kind: model-provider + 元数据(可选但推荐)
└── README.md # 安装说明(可选)
```
唯一必需的文件是 `__init__.py``plugin.yaml``hermes plugins` 用于自省,以及供通用 PluginManager 将插件路由到正确的加载器;若缺少该文件,通用加载器会回退到源码文本启发式检测。
## 最简示例——一个简单的 API key 提供商
```python
# plugins/model-providers/acme-inference/__init__.py
from providers import register_provider
from providers.base import ProviderProfile
acme = ProviderProfile(
name="acme-inference",
aliases=("acme",),
display_name="Acme Inference",
description="Acme — OpenAI-compatible direct API",
signup_url="https://acme.example.com/keys",
env_vars=("ACME_API_KEY", "ACME_BASE_URL"),
base_url="https://api.acme.example.com/v1",
auth_type="api_key",
default_aux_model="acme-small-fast",
fallback_models=(
"acme-large-v3",
"acme-medium-v3",
"acme-small-fast",
),
)
register_provider(acme)
```
```yaml
# plugins/model-providers/acme-inference/plugin.yaml
name: acme-inference
kind: model-provider
version: 1.0.0
description: Acme Inference — OpenAI-compatible direct API
author: Your Name
```
就这些。放入这两个文件后,以下集成**自动生效**,无需其他任何修改:
| 集成点 | 位置 | 获得的能力 |
|---|---|---|
| 凭据解析 | `hermes_cli/auth.py` | `PROVIDER_REGISTRY["acme-inference"]` 从 profile 填充 |
| `--provider` CLI 标志 | `hermes_cli/main.py` | 接受 `acme-inference` |
| `hermes model` 选择器 | `hermes_cli/models.py` | 出现在 `CANONICAL_PROVIDERS` 中,从 `{base_url}/models` 获取模型列表 |
| `hermes doctor` | `hermes_cli/doctor.py` | 对 `ACME_API_KEY``{base_url}/models` 进行健康检查 |
| `hermes setup` | `hermes_cli/config.py` | `ACME_API_KEY` 出现在 `OPTIONAL_ENV_VARS` 和设置向导中 |
| URL 反向映射 | `agent/model_metadata.py` | 主机名 → 提供商名称,用于自动检测 |
| 辅助模型 | `agent/auxiliary_client.py` | 使用 `default_aux_model` 进行压缩/摘要 |
| 运行时解析 | `hermes_cli/runtime_provider.py` | 返回正确的 `base_url``api_key``api_mode` |
| 传输层 | `agent/transports/chat_completions.py` | Profile 路径通过 `prepare_messages` / `build_extra_body` / `build_api_kwargs_extras` 生成 kwargs |
## ProviderProfile 字段
完整定义见 `providers/base.py`。最常用的字段:
| 字段 | 类型 | 用途 |
|---|---|---|
| `name` | str | 规范 ID——与 `config.yaml` 中的 `model.provider``--provider` 标志匹配 |
| `aliases` | `tuple[str, ...]` | 由 `get_provider_profile()` 解析的别名(如 `grok``xai` |
| `api_mode` | str | `chat_completions` \| `codex_responses` \| `anthropic_messages` \| `bedrock_converse` |
| `display_name` | str | 在 `hermes model` 选择器中显示的人类可读标签 |
| `description` | str | 选择器副标题 |
| `signup_url` | str | 首次运行设置时显示("在此获取 API key" |
| `env_vars` | `tuple[str, ...]` | 按优先级排列的 API key 环境变量;最后一个 `*_BASE_URL` 条目用作用户 base URL 覆盖 |
| `base_url` | str | 默认推理端点 |
| `models_url` | str | 显式目录 URL(回退到 `{base_url}/models` |
| `auth_type` | str | `api_key` \| `oauth_device_code` \| `oauth_external` \| `copilot` \| `aws_sdk` \| `external_process` |
| `fallback_models` | `tuple[str, ...]` | 实时目录获取失败时显示的精选列表 |
| `default_headers` | `dict[str, str]` | 随每个请求发送(如 Copilot 的 `Editor-Version` |
| `fixed_temperature` | Any | `None` = 使用调用方的值;`OMIT_TEMPERATURE` 哨兵值 = 完全不发送 temperatureKimi |
| `default_max_tokens` | `int \| None` | 提供商级别的 max_tokens 上限(Nvidia16384 |
| `default_aux_model` | str | 用于辅助任务(压缩、视觉、摘要)的廉价模型 |
## 可覆盖的 hook
对于非常规的特殊需求,可子类化 `ProviderProfile`
```python
from typing import Any
from providers.base import ProviderProfile
class AcmeProfile(ProviderProfile):
def prepare_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""提供商特定的消息预处理。在 codex 清理之后、developer-role 替换之前运行。
默认:直接透传。"""
# 示例:Qwen 将纯文本内容规范化为 list-of-parts 数组并注入 cache_control
# Kimi 重写 tool-call JSON
return messages
def build_extra_body(self, *, session_id=None, **context) -> dict:
"""提供商特定的 extra_body 字段,合并到 API 调用中。
context 包含:session_id、provider_preferences、model、base_url、
reasoning_config。默认:空 dict。"""
# 示例:OpenRouter 的 provider-preferences 块,
# Gemini 的 thinking_config 转换。
return {}
def build_api_kwargs_extras(self, *, reasoning_config=None, **context):
"""返回 (extra_body_additions, top_level_kwargs)。当某些字段需要放在顶层
Kimi 的 reasoning_effort)而另一些放在 extra_bodyOpenRouter 的 reasoning dict
时需要此方法。默认:({}, {})。"""
return {}, {}
def fetch_models(self, *, api_key=None, timeout=8.0) -> list[str] | None:
"""实时目录获取。默认使用 Bearer 认证访问 {models_url or base_url}/models。
以下情况需覆盖:自定义认证(Anthropic)、无 REST 端点(Bedrock → None),
或公开/无认证目录(OpenRouter)。"""
return super().fetch_models(api_key=api_key, timeout=timeout)
```
## Hook 参考示例
参考以下内置插件了解常用写法:
| 插件 | 参考原因 |
|---|---|
| `plugins/model-providers/openrouter/` | 带 provider preferences 的聚合器,公开模型目录 |
| `plugins/model-providers/gemini/` | `thinking_config` 转换(原生 + OpenAI 兼容嵌套形式) |
| `plugins/model-providers/kimi-coding/` | `OMIT_TEMPERATURE``extra_body.thinking`、顶层 `reasoning_effort` |
| `plugins/model-providers/qwen-oauth/` | 消息规范化、`cache_control` 注入、VL 高分辨率 |
| `plugins/model-providers/nous/` | 归因标签、"禁用时省略 reasoning" |
| `plugins/model-providers/custom/` | Ollama 的 `num_ctx` + `think: false` 特殊处理 |
| `plugins/model-providers/bedrock/` | `api_mode="bedrock_converse"``fetch_models` 返回 None(无 REST 端点) |
## 用户覆盖——不修改仓库替换内置提供商
假设你想将 `gmi` 指向私有测试端点进行测试。创建 `~/.hermes/plugins/model-providers/gmi/__init__.py`
```python
from providers import register_provider
from providers.base import ProviderProfile
register_provider(ProviderProfile(
name="gmi",
aliases=("gmi-cloud", "gmicloud"),
env_vars=("GMI_API_KEY",),
base_url="https://gmi-staging.internal.example.com/v1",
auth_type="api_key",
default_aux_model="google/gemini-3.1-flash-lite-preview",
))
```
下次会话时,`get_provider_profile("gmi").base_url` 将返回测试 URL。无需打补丁,无需重新构建。由于用户插件在内置插件之后被发现,用户的 `register_provider()` 调用会胜出。
## api_mode 选择
系统识别四个值。Hermes 的选择依据:
1. 用户显式覆盖(`config.yaml` 中设置了 `model.api_mode`
2. OpenCode 的按模型分发(Zen 和 Go 的 `opencode_model_api_mode`
3. URL 自动检测——`/anthropic` 后缀 → `anthropic_messages``api.openai.com``codex_responses``api.x.ai``codex_responses`Kimi 域名上的 `/coding``chat_completions`
4. **Profile 的 `api_mode`** 作为 URL 检测无结果时的回退
5. 默认 `chat_completions`
`profile.api_mode` 设置为你的提供商默认使用的值——它作为提示使用。用户 URL 覆盖仍然优先。
## 认证类型
| `auth_type` | 含义 | 使用者 |
|---|---|---|
| `api_key` | 单个环境变量携带静态 API key | 大多数提供商 |
| `oauth_device_code` | 设备码 OAuth 流程 | — |
| `oauth_external` | 用户在其他地方登录,token 存入 `auth.json` | Anthropic OAuth、MiniMax OAuth、Gemini Cloud Code、Qwen Portal、Nous Portal |
| `copilot` | GitHub Copilot token 刷新周期 | 仅 `copilot` 插件 |
| `aws_sdk` | AWS SDK 凭据链(IAM role、profile、env | 仅 `bedrock` 插件 |
| `external_process` | 认证由 agent 启动的子进程处理 | 仅 `copilot-acp` 插件 |
`auth_type` 控制哪些代码路径将你的提供商视为"简单 api-key 提供商"——若不是 `api_key`PluginManager 仍会记录 manifest,但 Hermes CLI 层面的自动化(doctor 检查、`--provider` 标志、设置向导委托)可能会跳过它。
## 发现时机
提供商发现是**懒加载**的——由进程中首次调用 `get_provider_profile()``list_providers()` 触发。实际上这在启动早期就会发生(`auth.py` 模块加载时会主动扩展 `PROVIDER_REGISTRY`)。若需验证插件是否已加载,运行:
```bash
hermes doctor
```
——成功的 `auth_type="api_key"` profile 会出现在 Provider Connectivity 部分,并附带 `/models` 探测结果。
编程方式检查:
```python
from providers import list_providers
for p in list_providers():
print(p.name, p.base_url, p.api_mode)
```
## 测试你的插件
`HERMES_HOME` 指向临时目录,避免污染真实配置:
```bash
export HERMES_HOME=/tmp/hermes-plugin-test
mkdir -p $HERMES_HOME/plugins/model-providers/my-provider
cat > $HERMES_HOME/plugins/model-providers/my-provider/__init__.py <<'EOF'
from providers import register_provider
from providers.base import ProviderProfile
register_provider(ProviderProfile(
name="my-provider",
env_vars=("MY_API_KEY",),
base_url="https://api.my-provider.example.com/v1",
auth_type="api_key",
))
EOF
export MY_API_KEY=your-test-key
hermes -z "hello" --provider my-provider -m some-model
```
## 通用 PluginManager 集成
通用 `PluginManager`(即 `hermes plugins` 操作的对象)**能看到**模型提供商插件,但不会导入它们——`providers/__init__.py` 负责管理其生命周期。Manager 记录 manifest 用于自省,并按 `kind: model-provider` 分类。当你将一个未标记的用户插件放入 `$HERMES_HOME/plugins/`,而该插件恰好调用了带 `ProviderProfile``register_provider`,Manager 会通过源码文本启发式检测自动将其归类为 `kind: model-provider`——因此即使没有 `plugin.yaml`,插件仍能正确路由。
## 通过 pip 分发
与所有 Hermes 插件一样,模型提供商可以作为 pip 包发布。在你的 `pyproject.toml` 中添加入口点:
```toml
[project.entry-points."hermes_agent.plugins"]
acme-inference = "acme_hermes_plugin:register"
```
……其中 `acme_hermes_plugin:register` 是一个调用 `register_provider(profile)` 的函数。通用 PluginManager 在 `discover_and_load()` 期间会拾取入口点插件。对于 `kind: model-provider` 的 pip 插件,你仍需在 manifest 中声明 kind(或依赖源码文本启发式检测)。
完整的入口点设置请参阅 [构建 Hermes 插件](/guides/build-a-hermes-plugin#distribute-via-pip)。
## 相关页面
- [Provider Runtime](/developer-guide/provider-runtime) — 解析优先级及各层读取 profile 的位置
- [添加提供商](/developer-guide/adding-providers) — 新推理后端的端到端检查清单(涵盖快速插件路径和完整 CLI/auth 集成)
- [Memory Provider 插件](/developer-guide/memory-provider-plugin)
- [Context Engine 插件](/developer-guide/context-engine-plugin)
- [构建 Hermes 插件](/guides/build-a-hermes-plugin) — 通用插件编写指南
@@ -0,0 +1,371 @@
---
sidebar_position: 11
title: "Plugin LLM 访问"
description: "通过 ctx.llm 在 plugin 内部运行任意 LLM 调用——支持对话或结构化输出、同步或异步。宿主持有认证凭据,失败关闭信任门控,可选 JSON Schema 验证。"
---
# Plugin LLM 访问
`ctx.llm` 是 plugin 发起 LLM 调用的官方方式。
对话补全、结构化提取、同步、异步、带或不带图像——
同一接口,同一信任门控,同一宿主持有的凭据。
Plugin 在需要涉及模型但又不属于 agent 对话的场景时使用它。
例如:将工具报错改写成非工程师也能理解的语言的 hook;
在消息入队前进行翻译的 gateway 适配器;
对长段粘贴内容进行摘要的斜杠命令;
对前一天活动评分并向状态看板写一行记录的定时任务;
以及决定某条消息是否值得唤醒 agent 的预过滤器。
这些任务不应让 agent 介入。它们只需要一次 LLM 调用、一个有类型的答案,然后结束。
## 最简调用
```python
result = ctx.llm.complete(messages=[{"role": "user", "content": "ping"}])
return result.text
```
这就是整个 API 的一行示例。无需密钥、无需 provider 配置、无需 SDK 初始化。Plugin 运行在用户当前使用的任意 provider 和模型上——用户切换 provider 时,plugin 自动跟随。
## 更完整的对话示例
```python
result = ctx.llm.complete(
messages=[
{"role": "system", "content": "Rewrite errors as one short sentence a non-engineer can act on."},
{"role": "user", "content": traceback_text},
],
max_tokens=64,
purpose="hooks.error-rewrite",
)
return result.text
```
`purpose` 是一个自由格式的审计字符串——它会出现在 `agent.log``result.audit` 中,方便运营人员查看哪个 plugin 发起了哪次调用。可选,但对于频繁触发的场景建议填写。
## 结构化输出
当 plugin 需要有类型的答案时,切换到结构化模式:
```python
result = ctx.llm.complete_structured(
instructions="Score this support reply for urgency (01) and pick a category.",
input=[{"type": "text", "text": message_body}],
json_schema=TRIAGE_SCHEMA,
purpose="support.triage",
temperature=0.0,
max_tokens=128,
)
if result.parsed["urgency"] > 0.8:
await dispatch_to_oncall(result.parsed["category"], message_body)
```
宿主向 provider 请求 JSON 输出,在本地作为兜底进行解析,若安装了 `jsonschema` 则对你的 schema 进行验证,最终在 `result.parsed` 上返回一个 Python 对象。如果模型无法生成有效 JSON,`result.parsed``None``result.text` 携带原始响应。
## 此模式的优势
* **一次调用,四种形态。** `complete()` 用于对话,`complete_structured()` 用于有类型的 JSON`acomplete()``acomplete_structured()` 用于 asyncio。参数相同,结果对象相同。
* **宿主持有凭据。** OAuth token、刷新流程、凭据池、每任务辅助覆盖——Hermes 已有的所有凭据概念均适用。Plugin 永远看不到 token;宿主通过 `result.audit` 将调用归因回溯。
* **有界。** 单次同步或异步调用。无流式输出,无工具循环,无需管理对话状态。给定输入,获取结果,返回。
* **失败关闭信任。** 从未配置过的 plugin 无法自行选择 provider、模型、agent 或存储的凭据。默认行为是"使用用户正在使用的"。运营人员在 `config.yaml` 中按 plugin 逐一选择开启特定覆盖。
## 快速开始
以下是两个完整的 plugin 示例——一个对话,一个结构化。两者均在单个 `register(ctx)` 函数中实现,无需任何外部配置即可针对用户当前激活的模型运行。
### 对话补全——`/tldr`
```python
def register(ctx):
ctx.register_command(
name="tldr",
handler=lambda raw: _tldr(ctx, raw),
description="Summarise the supplied text in one paragraph.",
args_hint="<text>",
)
def _tldr(ctx, raw_args: str) -> str:
text = raw_args.strip()
if not text:
return "Usage: /tldr <text to summarise>"
result = ctx.llm.complete(
messages=[
{"role": "system",
"content": "Summarise the user's text in one tight paragraph. No preamble."},
{"role": "user", "content": text},
],
max_tokens=256,
temperature=0.3,
purpose="tldr",
)
return result.text
```
`result.text` 是模型的响应;`result.usage` 携带 token 计数;`result.provider``result.model` 携带归因信息。
### 结构化提取——`/paste-to-tasks`
```python
def register(ctx):
ctx.register_command(
name="paste-to-tasks",
handler=lambda raw: _paste_to_tasks(ctx, raw),
description="Turn freeform meeting notes into structured tasks.",
args_hint="<text>",
)
_TASKS_SCHEMA = {
"type": "object",
"properties": {
"tasks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"owner": {"type": "string"},
"action": {"type": "string"},
"due": {"type": "string", "description": "ISO date or empty"},
},
"required": ["action"],
},
},
},
"required": ["tasks"],
}
def _paste_to_tasks(ctx, raw_args: str) -> str:
if not raw_args.strip():
return "Usage: /paste-to-tasks <meeting notes>"
result = ctx.llm.complete_structured(
instructions=(
"Extract concrete action items from these meeting notes. "
"One task per actionable line. If no owner is named, leave 'owner' blank."
),
input=[{"type": "text", "text": raw_args}],
json_schema=_TASKS_SCHEMA,
schema_name="meeting.tasks",
purpose="paste-to-tasks",
temperature=0.0,
max_tokens=512,
)
if result.parsed is None:
return f"Couldn't parse a response. Raw output:\n{result.text}"
lines = [f"- [{t.get('owner') or '?'}] {t['action']}" for t in result.parsed["tasks"]]
return "\n".join(lines) or "(no tasks found)"
```
第三个完整示例(包含图像输入)位于
[`hermes-example-plugins`](https://github.com/NousResearch/hermes-example-plugins/tree/main/plugin-llm-example)
仓库(参考 plugin 的配套仓库——不随 hermes-agent 本体打包)。关于异步接口(`acomplete()` / `acomplete_structured()``asyncio.gather()` 配合使用),请参见同一仓库中的
[`plugin-llm-async-example`](https://github.com/NousResearch/hermes-example-plugins/tree/main/plugin-llm-async-example)。
## 何时使用哪种方式
| 你需要…… | 使用 |
|---|---|
| 自由格式文本响应(翻译、摘要、改写、生成) | `complete()` |
| 多轮 promptsystem + few-shot 示例 + user | `complete()` |
| 经 schema 验证的有类型 dict | `complete_structured()` |
| 图像或文本输入并返回有类型 dict | `complete_structured()` |
| 在异步代码中发起相同调用(gateway 适配器、异步 hook | `acomplete()` / `acomplete_structured()` |
其他所有内容——provider 选择、模型解析、认证、回退、超时、视觉路由——在四种形态中完全一致。
## API 接口
`ctx.llm``agent.plugin_llm.PluginLlm` 的实例。
### `complete()`
```python
result = ctx.llm.complete(
messages=[{"role": "user", "content": "Hi"}],
provider=None, # 可选,受门控——Hermes provider id(如 "openrouter"
model=None, # 可选,受门控——该 provider 期望的任意字符串
temperature=None,
max_tokens=None,
timeout=None, # 秒
agent_id=None, # 可选,受门控
profile=None, # 可选,受门控——显式指定认证 profile 名称
purpose="optional-audit-string",
)
# → PluginLlmCompleteResult(text, provider, model, agent_id, usage, audit)
```
普通对话补全。`messages` 采用标准 OpenAI 格式——`{"role": "...", "content": "..."}` 字典列表。多轮 promptsystem + few-shot user/assistant 对 + 最终 user)的用法与 OpenAI SDK 完全一致。
`provider=``model=` 相互独立,格式与宿主主配置(`model.provider` + `model.model`)相同。仅设置 `model=` 可在用户当前激活的 provider 上使用不同模型。同时设置两者则完全切换 provider。任一参数在未获运营人员授权时均会抛出 `PluginLlmTrustError`
### `complete_structured()`
```python
result = ctx.llm.complete_structured(
instructions="What you want extracted.",
input=[
{"type": "text", "text": "..."},
{"type": "image", "data": b"...", "mime_type": "image/png"},
{"type": "image", "url": "https://..."},
],
json_schema={...}, # 可选——触发解析结果及验证
json_mode=False, # 设为 True 可在不提供 schema 的情况下请求 JSON
schema_name=None, # 可选的人类可读 schema 名称
system_prompt=None,
provider=None, # 可选,受门控
model=None, # 可选,受门控
temperature=None,
max_tokens=None,
timeout=None,
agent_id=None,
profile=None,
purpose=None,
)
# → PluginLlmStructuredResult(text, provider, model, agent_id,
# usage, parsed, content_type, audit)
```
输入为有类型的文本或图像块(原始字节会自动 base64 编码为 `data:` URL)。当提供 `json_schema` 或设置 `json_mode=True` 时,宿主通过 `response_format` 向 provider 请求 JSON 输出,在本地作为兜底进行解析,若安装了 `jsonschema` 则对你的 schema 进行验证。
* `result.content_type == "json"``result.parsed` 是符合你 schema 的 Python 对象。
* `result.content_type == "text"` — 解析或验证失败;检查 `result.text` 获取原始模型响应。
### 异步
```python
result = await ctx.llm.acomplete(messages=...)
result = await ctx.llm.acomplete_structured(instructions=..., input=...)
```
参数和结果类型与对应的同步版本相同。在 gateway 适配器、异步 hook 或任何已运行在 asyncio 事件循环上的 plugin 代码中使用。
### 结果属性
```python
@dataclass
class PluginLlmCompleteResult:
text: str # 助手的响应
provider: str # 如 "openrouter"、"anthropic"
model: str # provider 为本次调用返回的模型标识
agent_id: str # 使用了哪个 agent 的模型/认证
usage: PluginLlmUsage # token 数 + 缓存 + 费用估算
audit: Dict[str, Any] # plugin_id、purpose、profile
@dataclass
class PluginLlmStructuredResult(PluginLlmCompleteResult):
parsed: Optional[Any] # content_type == "json" 时的 JSON 对象
content_type: str # "json" 或 "text"
# 提供 schema_name 时 audit 中也会携带该字段
```
当 provider 返回相应字段时,`usage` 携带 `input_tokens``output_tokens``total_tokens``cache_read_tokens``cache_write_tokens``cost_usd`
## 信任门控
默认行为是失败关闭。在没有 `plugins.entries` 配置块的情况下,plugin 可以:
* 针对用户当前激活的 provider 和模型运行四种方法中的任意一种,
* 设置请求塑形参数(`temperature``max_tokens``timeout``system_prompt``purpose``messages``instructions``input``json_schema`),
……仅此而已。`provider=``model=``agent_id=``profile=` 参数在运营人员授权前均会抛出 `PluginLlmTrustError`
**大多数 plugin 永远不需要此部分。** 仅调用 `ctx.llm.complete(messages=...)` 且不带任何覆盖的 plugin,会针对用户当前激活的内容运行,零配置即可工作。以下配置块仅在 plugin 明确需要固定到与用户不同的模型或 provider 时才有意义。
```yaml
plugins:
entries:
my-plugin:
llm:
# 允许此 plugin 选择不同的 Hermes provider
# (必须是 Hermes 已知的 provider——与
# `hermes model` 和 config.yaml model.provider 中的名称相同)
allow_provider_override: true
# 可选:限制允许的 provider。使用 ["*"] 表示任意。
allowed_providers:
- openrouter
- anthropic
# 允许此 plugin 请求特定模型。
allow_model_override: true
# 可选:限制允许的模型。使用 ["*"] 表示任意。
# 模型与 plugin 发送的字符串进行字面匹配——
# Hermes 不做任何查找。
allowed_models:
- openai/gpt-4o-mini
- anthropic/claude-3-5-haiku
# 允许跨 agent 调用(罕见)。
allow_agent_id_override: false
# 允许 plugin 请求特定的存储认证 profile
# (如同一 provider 上的不同 OAuth 账户)。
allow_profile_override: false
```
Plugin id 对于扁平 plugin 是 manifest 中的 `name:` 字段,对于嵌套 plugin 是路径派生的键(`image_gen/openai``memory/honcho` 等)。
### 门控执行内容
| 覆盖项 | 默认 | 配置键 |
| --------------- | ----- | -------------------------------- |
| `provider=` | 拒绝 | `allow_provider_override: true` |
| ↳ 允许列表 | — | `allowed_providers: [...]` |
| `model=` | 拒绝 | `allow_model_override: true` |
| ↳ 允许列表 | — | `allowed_models: [...]` |
| `agent_id=` | 拒绝 | `allow_agent_id_override: true` |
| `profile=` | 拒绝 | `allow_profile_override: true` |
每项覆盖独立门控。授予 `allow_model_override` **不会**同时授予 `allow_provider_override`——被信任可选择模型的 plugin,在未获得 provider 门控授权前仍固定在用户当前激活的 provider 上。
### 门控无需执行的内容
* 请求塑形参数——`temperature``max_tokens``timeout``system_prompt``purpose``messages``instructions``input``json_schema``schema_name``json_mode`——始终允许;它们不涉及凭据或路由选择。
* 默认拒绝策略意味着未配置的 plugin 仍可完成有用的工作——只是针对当前激活的 provider 和模型运行。运营人员只需在 plugin 明确需要更精细路由时才考虑 `plugins.entries`
## 宿主负责的内容
以下是 `ctx.llm` 为 plugin 代劳的完整列表,你无需自行处理:
* **Provider 解析。** 从用户配置中读取 `model.provider` + `model.model`(或在受信任时读取显式覆盖值)。
* **认证。** 从 `~/.hermes/auth.json` / 环境变量中提取 API 密钥、OAuth token 或刷新 token,包括配置了凭据池时的处理。Plugin 永远看不到这些内容。
* **视觉路由。** 当提供图像输入而用户当前激活的文本模型仅支持文本时,宿主自动回退到已配置的视觉模型。
* **回退链。** 若用户主 provider 返回 5xx 或 429,请求在向 plugin 返回错误前会经过 Hermes 常规的聚合器感知回退流程。
* **超时。** 遵循你的 `timeout=` 参数,回退到 `auxiliary.<task>.timeout` 配置或全局辅助默认值。
* **JSON 塑形。** 在你请求 JSON 时向 provider 发送 `response_format`,若 provider 返回了代码围栏格式的响应则在本地重新解析。
* **Schema 验证。** 安装了 `jsonschema` 时对你的 `json_schema` 进行验证;否则记录一行 debug 日志并跳过严格验证。
* **审计日志。** 每次调用向 `agent.log` 写入一条 INFO 日志,包含 plugin id、provider/模型、purpose 和 token 总量。
## Plugin 负责的内容
* **请求结构。** 对话用 `messages`,结构化用 `instructions` + `input`。Plugin 构建 prompt(提示词);宿主执行它。
* **Schema。** 你期望返回的任意结构。宿主不会为你推断。
* **错误处理。** `complete_structured()` 在输入为空或 schema 验证失败时抛出 `ValueError`。信任门控拒绝覆盖时抛出 `PluginLlmTrustError`。其他情况(provider 5xx、未配置凭据、超时)抛出 `auxiliary_client.call_llm()` 本身抛出的异常。
* **费用。** 每次调用都针对用户的付费 provider 运行。不要在不考虑 token 消耗的情况下对每条 gateway 消息循环调用 `complete()`
## 在 plugin 接口中的定位
现有 `ctx.*` 方法各自扩展一个已有的 Hermes 子系统:
| `ctx.register_tool` | 添加 agent 可调用的工具 |
| `ctx.register_platform` | 接入新的 gateway 适配器 |
| `ctx.register_image_gen_provider` | 替换图像生成后端 |
| `ctx.register_memory_provider` | 替换记忆后端 |
| `ctx.register_context_engine` | 替换上下文压缩器 |
| `ctx.register_hook` | 监听生命周期事件 |
`ctx.llm` 是第一个允许 plugin 在*带外*运行用户正在对话的同一模型的接口,无需上述任何注册。这是它唯一的职责。如果你的 plugin 需要注册一个由 agent 调用的工具,使用 `register_tool`。如果需要响应生命周期事件,使用 `register_hook`。如果需要发起自己的模型调用——无论出于何种原因,结构化与否——使用 `ctx.llm`
## 参考资料
* 实现:[`agent/plugin_llm.py`](https://github.com/NousResearch/hermes-agent/blob/main/agent/plugin_llm.py)
* 测试:[`tests/agent/test_plugin_llm.py`](https://github.com/NousResearch/hermes-agent/blob/main/tests/agent/test_plugin_llm.py)
* 参考 plugin(配套仓库):
* [`plugin-llm-example`](https://github.com/NousResearch/hermes-example-plugins/tree/main/plugin-llm-example) — 带图像输入的同步结构化提取
* [`plugin-llm-async-example`](https://github.com/NousResearch/hermes-example-plugins/tree/main/plugin-llm-async-example) — 使用 `asyncio.gather()` 的异步示例
* 辅助客户端(底层引擎):参见
[Provider 运行时](/developer-guide/provider-runtime)。
@@ -0,0 +1,126 @@
---
sidebar_position: 8
title: "程序化集成"
description: "从外部程序驱动 hermes-agent 的三种协议:ACP、TUI gateway JSON-RPC 以及兼容 OpenAI 的 HTTP API"
---
# 程序化集成
Hermes 提供三种协议,供外部程序驱动 agent——IDE 插件、自定义 UI、CI 流水线、嵌入式子 agent。根据你的传输方式和消费端选择合适的协议。
| 协议 | 传输方式 | 适用场景 | 定义位置 |
|----------|-----------|----------|------------|
| **ACP** | JSON-RPC over stdio | 已支持 [Agent Client Protocol](https://github.com/zed-industries/agent-client-protocol) 的 IDE 客户端(VS Code、Zed、JetBrains | `acp_adapter/` |
| **TUI gateway** | JSON-RPC over stdio(或 WebSocket | 需要精细控制会话、slash 命令、审批及流式事件的自定义宿主 | `tui_gateway/server.py` |
| **API server** | HTTP + Server-Sent Events | 兼容 OpenAI 的前端(Open WebUI、LobeChat、LibreChat……)及语言无关的 Web 客户端 | `gateway/platforms/api_server.py` |
三种协议均驱动同一个 `AIAgent` 核心,区别仅在于线路格式和所暴露的功能集。
---
## ACPAgent Client Protocol
`hermes acp` 启动一个基于 stdio 的 JSON-RPC 服务器,使用 ACP 协议。已在 VS CodeZed Industries 的 ACP 扩展)、Zed 以及所有安装了 ACP 插件的 JetBrains IDE 中投入生产使用。
暴露的能力:会话创建、prompt(提示词)提交、流式 agent 消息块、工具调用事件、权限请求、会话 fork、取消及身份验证。工具输出会被渲染为 IDE 可理解的 ACP `Diff`/`ToolCall` 内容块。
完整生命周期、事件桥接及审批流程:[ACP 内部机制](./acp-internals)。
```bash
hermes acp # 在 stdio 上提供 ACP 服务
hermes acp --bootstrap # 打印适用于支持 ACP 的 IDE 的安装代码片段
```
---
## TUI Gateway JSON-RPC
`tui_gateway/server.py` 是 Ink TUI`hermes --tui`)和嵌入式仪表板 PTY 桥接所使用的协议。任何外部宿主均可通过 stdio(或经由 `tui_gateway/ws.py` 的 WebSocket)使用相同协议。
### 方法目录(精选)
```
prompt.submit prompt.background session.steer
session.create session.list session.interrupt
session.history session.compress session.branch
session.title session.usage session.status
clarify.respond sudo.respond secret.respond
approval.respond config.set / config.get commands.catalog
command.resolve command.dispatch cli.exec
reload.mcp reload.env process.stop
delegation.status subagent.interrupt spawn_tree.save / list / load
terminal.resize clipboard.paste image.attach
```
### 流式返回的事件
`message.delta``message.complete``tool.start``tool.progress``tool.complete``approval.request``clarify.request``sudo.request``secret.request``gateway.ready`,以及会话生命周期和错误事件。
### Pi 风格 RPC 映射
Pi-mono RPC 规范([issue #360](https://github.com/NousResearch/hermes-agent/issues/360))中的每条命令均有对应的 TUI gateway 等价项:
| Pi 命令 | Hermes 等价项 |
|------------|-------------------|
| `prompt` | `prompt.submit`(或 ACP `session/prompt` |
| `steer` | `session.steer` |
| `follow_up` | 在当前轮次结束后排队的 `prompt.submit` |
| `abort` | `session.interrupt` |
| `set_model` | 通过 `command.dispatch` 执行 `/model <provider:model>`(会话中途生效,持久化) |
| `compact` | `session.compress` |
| `get_state` | `session.status` |
| `get_messages` | `session.history` |
| `switch_session` | `session.resume` |
| `fork` | `session.branch` |
| `ui_request` / `ui_response` | `clarify.respond` / `sudo.respond` / `secret.respond` / `approval.respond` |
---
## 兼容 OpenAI 的 API Server
`gateway/platforms/api_server.py` 通过 HTTP 暴露 Hermes,供任何已支持 OpenAI 格式的客户端使用。适用于需要 Web 前端、curl 驱动的 CI 运行器或非 Python 消费端的场景。
端点:
```
POST /v1/chat/completions OpenAI Chat Completions(通过 SSE 流式传输)
POST /v1/responses OpenAI Responses API(有状态)
POST /v1/runs 启动一次运行,返回 run_id(202)
GET /v1/runs/{id} 运行状态
GET /v1/runs/{id}/events 生命周期事件的 SSE 流
POST /v1/runs/{id}/approval 解决待处理的审批
POST /v1/runs/{id}/stop 中断运行
GET /v1/capabilities 机器可读的功能标志
GET /v1/models 列出 hermes-agent
GET /health, /health/detailed
```
配置、请求头(`X-Hermes-Session-Id``X-Hermes-Session-Key`)及前端接入:[API Server](../user-guide/features/api-server)。
---
## 该选哪个?
- **正在编写 IDE 插件,且 IDE 已支持 ACP** → 选 ACP。IDE 侧无需任何协议工作。
- **正在编写自定义桌面 / Web / TUI 宿主,且需要 Hermes 的全部功能**slash 命令、审批、clarify、多 agent、会话分支)→ 选 TUI gateway JSON-RPC。
- **需要任意兼容 OpenAI 的前端、语言无关的 HTTP 客户端或 curl 驱动的自动化** → 选 API server。
- **需要在 Python 进程内嵌入,不想启动子进程** → 直接导入 `run_agent.AIAgent`。参见 [Agent Loop](./agent-loop)。
---
## 模型热切换
会话中途切换模型在所有接入方式上均可用——底层均为 `/model` slash 命令。
- **CLI / TUI** `/model claude-sonnet-4``/model openrouter:anthropic/claude-sonnet-4.6`
- **TUI gateway RPC** 使用 `{"command": "/model claude-sonnet-4"}` 调用 `command.dispatch`
- **ACP** IDE 将 slash 命令作为 prompt 发送,agent 负责分发
- **API server** 在请求体中包含 `model` 字段,或设置 `X-Hermes-Model`
内置 provider 感知解析(相同的模型名称会根据当前 provider 自动选择正确格式)。参见 `hermes_cli/model_switch.py`
---
## 关于 `--mode rpc` 的说明
Hermes 没有 `--mode rpc` 标志。上述三种协议已覆盖所有使用场景——ACP 用于 IDE 协议客户端,TUI gateway 用于 stdio JSON-RPC 宿主,API server 用于 HTTP。如果你发现上述协议均无法满足的真实需求,请提交 issue 并说明你正在构建的具体消费端。
@@ -0,0 +1,270 @@
---
sidebar_position: 5
title: "Prompt 组装"
description: "Hermes 如何构建系统 prompt、保持缓存稳定性并注入临时层"
---
# Prompt 组装
Hermes 刻意将以下内容分离:
- **已缓存的系统 prompt 状态**
- **API 调用时临时添加的内容**
这是项目中最重要的设计决策之一,因为它影响:
- token 用量
- prompt 缓存效果
- 会话连续性
- 记忆正确性
主要文件:
- `run_agent.py`
- `agent/prompt_builder.py`
- `tools/memory_tool.py`
## 已缓存的系统 prompt 层
已缓存的系统 prompt 大致按以下顺序组装:
1. agent 身份 — 优先使用 `HERMES_HOME` 中的 `SOUL.md`,否则回退到 `prompt_builder.py` 中的 `DEFAULT_AGENT_IDENTITY`
2. 工具感知行为指导
3. Honcho 静态块(激活时)
4. 可选系统消息
5. 冻结的 MEMORY 快照
6. 冻结的 USER 配置文件快照
7. skills 索引
8. 上下文文件(`AGENTS.md``.cursorrules``.cursor/rules/*.mdc`)— 若 SOUL.md 已在第 1 步作为身份加载,则此处**不**再包含它
9. 时间戳 / 可选会话 ID
10. 平台提示
当设置了 `skip_context_files`(例如子 agent 委托)时,不会加载 SOUL.md,而是使用硬编码的 `DEFAULT_AGENT_IDENTITY`
### 具体示例:组装后的系统 prompt
以下是所有层都存在时最终系统 prompt 的简化视图(注释说明每个部分的来源):
```
# Layer 1: Agent Identity (from ~/.hermes/SOUL.md)
You are Hermes, an AI assistant created by Nous Research.
You are an expert software engineer and researcher.
You value correctness, clarity, and efficiency.
...
# Layer 2: Tool-aware behavior guidance
You have persistent memory across sessions. Save durable facts using
the memory tool: user preferences, environment details, tool quirks,
and stable conventions. Memory is injected into every turn, so keep
it compact and focused on facts that will still matter later.
...
When the user references something from a past conversation or you
suspect relevant cross-session context exists, use session_search
to recall it before asking them to repeat themselves.
# Tool-use enforcement (for GPT/Codex models only)
You MUST use your tools to take action — do not describe what you
would do or plan to do without actually doing it.
...
# Layer 3: Honcho static block (when active)
[Honcho personality/context data]
# Layer 4: Optional system message (from config or API)
[User-configured system message override]
# Layer 5: Frozen MEMORY snapshot
## Persistent Memory
- User prefers Python 3.12, uses pyproject.toml
- Default editor is nvim
- Working on project "atlas" in ~/code/atlas
- Timezone: US/Pacific
# Layer 6: Frozen USER profile snapshot
## User Profile
- Name: Alice
- GitHub: alice-dev
# Layer 7: Skills index
## Skills (mandatory)
Before replying, scan the skills below. If one clearly matches
your task, load it with skill_view(name) and follow its instructions.
...
<available_skills>
software-development:
- code-review: Structured code review workflow
- test-driven-development: TDD methodology
research:
- arxiv: Search and summarize arXiv papers
</available_skills>
# Layer 8: Context files (from project directory)
# Project Context
The following project context files have been loaded and should be followed:
## AGENTS.md
This is the atlas project. Use pytest for testing. The main
entry point is src/atlas/main.py. Always run `make lint` before
committing.
# Layer 9: Timestamp + session
Current time: 2026-03-30T14:30:00-07:00
Session: abc123
# Layer 10: Platform hint
You are a CLI AI Agent. Try not to use markdown but simple text
renderable inside a terminal.
```
## SOUL.md 在 prompt 中的位置
`SOUL.md` 位于 `~/.hermes/SOUL.md`,作为 agent 的身份标识——系统 prompt 的第一个部分。`prompt_builder.py` 中的加载逻辑如下:
```python
# From agent/prompt_builder.py (simplified)
def load_soul_md() -> Optional[str]:
soul_path = get_hermes_home() / "SOUL.md"
if not soul_path.exists():
return None
content = soul_path.read_text(encoding="utf-8").strip()
content = _scan_context_content(content, "SOUL.md") # Security scan
content = _truncate_content(content, "SOUL.md") # Cap at 20k chars
return content
```
`load_soul_md()` 返回内容时,它会替换硬编码的 `DEFAULT_AGENT_IDENTITY`。随后调用 `build_context_files_prompt()` 时传入 `skip_soul=True`,以防止 SOUL.md 出现两次(一次作为身份,一次作为上下文文件)。
`SOUL.md` 不存在,系统将回退到:
```
You are Hermes Agent, an intelligent AI assistant created by Nous Research.
You are helpful, knowledgeable, and direct. You assist users with a wide
range of tasks including answering questions, writing and editing code,
analyzing information, creative work, and executing actions via your tools.
You communicate clearly, admit uncertainty when appropriate, and prioritize
being genuinely useful over being verbose unless otherwise directed below.
Be targeted and efficient in your exploration and investigations.
```
## 上下文文件的注入方式
`build_context_files_prompt()` 使用**优先级系统**——只加载一种项目上下文类型(先匹配先赢):
```python
# From agent/prompt_builder.py (simplified)
def build_context_files_prompt(cwd=None, skip_soul=False):
cwd_path = Path(cwd).resolve()
# Priority: first match wins — only ONE project context loaded
project_context = (
_load_hermes_md(cwd_path) # 1. .hermes.md / HERMES.md (walks to git root)
or _load_agents_md(cwd_path) # 2. AGENTS.md (cwd only)
or _load_claude_md(cwd_path) # 3. CLAUDE.md (cwd only)
or _load_cursorrules(cwd_path) # 4. .cursorrules / .cursor/rules/*.mdc
)
sections = []
if project_context:
sections.append(project_context)
# SOUL.md from HERMES_HOME (independent of project context)
if not skip_soul:
soul_content = load_soul_md()
if soul_content:
sections.append(soul_content)
if not sections:
return ""
return (
"# Project Context\n\n"
"The following project context files have been loaded "
"and should be followed:\n\n"
+ "\n".join(sections)
)
```
### 上下文文件发现详情
| 优先级 | 文件 | 搜索范围 | 说明 |
|--------|------|----------|------|
| 1 | `.hermes.md``HERMES.md` | 从 CWD 向上至 git 根目录 | Hermes 原生项目配置 |
| 2 | `AGENTS.md` | 仅 CWD | 常见 agent 指令文件 |
| 3 | `CLAUDE.md` | 仅 CWD | Claude Code 兼容性 |
| 4 | `.cursorrules``.cursor/rules/*.mdc` | 仅 CWD | Cursor 兼容性 |
所有上下文文件均会:
- **安全扫描** — 检查 prompt 注入模式(不可见 unicode、"ignore previous instructions"、凭据窃取尝试)
- **截断处理** — 使用 70/20 头尾比例上限为 20,000 字符,并附截断标记
- **剥离 YAML frontmatter** — `.hermes.md` 的 frontmatter 会被移除(保留供未来配置覆盖使用)
## 仅在 API 调用时生效的层
以下内容刻意*不*作为已缓存系统 prompt 的一部分持久化:
- `ephemeral_system_prompt`
- prefill 消息
- gateway 派生的会话上下文覆盖层
- 注入当前轮次用户消息的后续轮次 Honcho 召回内容
这种分离使稳定前缀保持稳定,从而有效缓存。
## 记忆快照
本地记忆和用户配置文件数据在会话开始时作为冻结快照注入。会话中途的写入操作会更新磁盘状态,但不会修改已构建的系统 prompt,直到新会话开始或强制重建时才生效。
## 上下文文件
`agent/prompt_builder.py` 使用**优先级系统**扫描并清理项目上下文文件——只加载一种类型(先匹配先赢):
1. `.hermes.md` / `HERMES.md`(向上遍历至 git 根目录)
2. `AGENTS.md`(启动时的 CWD;子目录在会话期间通过 `agent/subdirectory_hints.py` 逐步发现)
3. `CLAUDE.md`(仅 CWD
4. `.cursorrules` / `.cursor/rules/*.mdc`(仅 CWD
`SOUL.md` 通过 `load_soul_md()` 单独加载用于身份槽位。加载成功后,`build_context_files_prompt(skip_soul=True)` 会防止其出现两次。
长文件在注入前会被截断。
## Skills 索引
当 skills 工具可用时,skills 系统会向 prompt 贡献一个紧凑的 skills 索引。
## 支持的 prompt 自定义入口
大多数用户应将 `agent/prompt_builder.py` 视为实现代码,而非配置入口。推荐的自定义路径是修改 Hermes 已加载的 prompt 输入,而非直接编辑 Python 模板。
### 优先使用这些入口
- `~/.hermes/SOUL.md` — 用自定义 agent 角色和固定行为替换内置默认身份块。
- `~/.hermes/MEMORY.md``~/.hermes/USER.md` — 提供应在新会话中快照的持久跨会话事实和用户配置文件数据。
- 项目上下文文件,如 `.hermes.md``HERMES.md``AGENTS.md``CLAUDE.md``.cursorrules` — 注入仓库特定的工作规则。
- Skills — 打包可复用的工作流和参考资料,无需编辑核心 prompt 代码。
- 可选系统 prompt 配置 / API 覆盖 — 添加部署特定的指令文本,无需 fork Hermes。
- 临时覆盖层,如 `HERMES_EPHEMERAL_SYSTEM_PROMPT` 或 prefill 消息 — 添加不应成为已缓存 prompt 前缀一部分的轮次级指导。
### 何时应编辑代码
仅当你刻意维护一个 fork 或向上游贡献行为变更时,才编辑 `agent/prompt_builder.py`。该文件为每个会话组装 prompt 管道、缓存边界和注入顺序。直接编辑该文件是全局产品变更,而非针对单个用户的 prompt 自定义。
换言之:
- 若想要不同的助手身份,编辑 `SOUL.md`
- 若想要不同的仓库规则,编辑项目上下文文件
- 若想要可复用的操作流程,添加或修改 skills
- 若想改变 Hermes 为所有人组装 prompt 的方式,修改 Python 代码并将其视为代码贡献
## Prompt 组装为何如此拆分
该架构刻意优化以:
- 保留提供商侧的 prompt 缓存
- 避免不必要地修改历史记录
- 保持记忆语义清晰可理解
- 允许 gateway/ACP/CLI 添加上下文而不污染持久 prompt 状态
## 相关文档
- [上下文压缩与 Prompt 缓存](./context-compression-and-caching.md)
- [会话存储](./session-storage.md)
- [Gateway 内部机制](./gateway-internals.md)
@@ -0,0 +1,202 @@
---
sidebar_position: 4
title: "Provider 运行时解析"
description: "Hermes 如何在运行时解析 provider、凭据、API 模式及辅助模型"
---
# Provider 运行时解析
Hermes 拥有一个共享的 provider 运行时解析器,用于以下场景:
- CLI
- gateway
- cron 任务
- ACP
- 辅助模型调用
主要实现:
- `hermes_cli/runtime_provider.py` — 凭据解析,`_resolve_custom_runtime()`
- `hermes_cli/auth.py` — provider 注册表,`resolve_provider()`
- `hermes_cli/model_switch.py` — 共享 `/model` 切换流水线(CLI + gateway
- `agent/auxiliary_client.py` — 辅助模型路由
- `providers/` — ABC + 注册表入口点(`ProviderProfile``register_provider``get_provider_profile``list_providers`
- `plugins/model-providers/<name>/` — 每个 provider 的插件(内置),声明 `api_mode``base_url``env_vars``fallback_models` 并在首次访问时将自身注册到注册表。用户插件位于 `$HERMES_HOME/plugins/model-providers/<name>/`,会覆盖同名的内置插件。
`providers/` 中的 `get_provider_profile()` 为给定 provider id 返回一个 `ProviderProfile``runtime_provider.py` 在解析时调用它,以获取规范的 `base_url``env_vars` 优先级列表、`api_mode``fallback_models`,无需在多个文件中重复这些数据。在 `plugins/model-providers/<your-provider>/`(或 `$HERMES_HOME/plugins/model-providers/<your-provider>/`)下添加一个调用 `register_provider()` 的新插件,即可让 `runtime_provider.py` 自动识别它——无需在解析器本身中添加分支。
如果你想添加一个新的一等推理 provider,请结合本页阅读 [添加 Provider](./adding-providers.md) 和 [Model Provider 插件指南](./model-provider-plugin.md)。
## 解析优先级
从高层来看,provider 解析使用以下顺序:
1. 显式 CLI/运行时请求
2. `config.yaml` 中的模型/provider 配置
3. 环境变量
4. provider 特定的默认值或自动解析
该顺序很重要,因为 Hermes 将已保存的模型/provider 选择视为正常运行的真实来源。这可以防止过时的 shell 导出变量悄悄覆盖用户在 `hermes model` 中最后选择的端点。
## Provider
当前 provider 系列包括(完整内置集合见 `plugins/model-providers/`):
- OpenRouter
- Nous Portal
- OpenAI Codex
- Copilot / Copilot ACP
- Anthropic(原生)
- Google / Gemini`gemini``google-gemini-cli`
- Alibaba / DashScope`alibaba``alibaba-coding-plan`
- DeepSeek
- Z.AI
- Kimi / Moonshot`kimi-coding``kimi-coding-cn`
- MiniMax`minimax``minimax-cn``minimax-oauth`
- Kilo Code
- Hugging Face
- OpenCode Zen / OpenCode Go
- AWS Bedrock
- Azure Foundry
- NVIDIA NIM
- xAIGrok
- Arcee
- GMI Cloud
- StepFun
- Qwen OAuth
- Xiaomi
- Ollama Cloud
- LM Studio
- Tencent TokenHub
- Custom`provider: custom`)— 适用于任何 OpenAI 兼容端点的一等 provider
- 命名自定义 provider`config.yaml` 中的 `custom_providers` 列表)
## 运行时解析的输出
运行时解析器返回的数据包括:
- `provider`
- `api_mode`
- `base_url`
- `api_key`
- `source`
- provider 特定的元数据,如过期/刷新信息
## 为什么这很重要
该解析器是 Hermes 能够在以下场景之间共享认证/运行时逻辑的主要原因:
- `hermes chat`
- gateway 消息处理
- 在全新会话中运行的 cron 任务
- ACP 编辑器会话
- 辅助模型任务
## OpenRouter 与自定义 OpenAI 兼容 base URL
Hermes 包含相关逻辑,以避免在存在多个 provider 密钥时(例如同时存在 `OPENROUTER_API_KEY``OPENAI_API_KEY`)将错误的 API key 泄露给自定义端点。
每个 provider 的 API key 仅作用于其自身的 base URL
- `OPENROUTER_API_KEY` 仅发送至 `openrouter.ai` 端点
- `OPENAI_API_KEY` 用于自定义端点及作为回退
Hermes 还区分以下两种情况:
- 用户主动选择的真实自定义端点
- 未配置自定义端点时使用的 OpenRouter 回退路径
这种区分对以下场景尤为重要:
- 本地模型服务器
- 非 OpenRouter 的 OpenAI 兼容 API
- 无需重新运行 setup 即可切换 provider
- 通过 config 保存的自定义端点,即使当前 shell 中未导出 `OPENAI_BASE_URL` 也应正常工作
## 原生 Anthropic 路径
Anthropic 不再仅限于"通过 OpenRouter"访问。
当 provider 解析选择 `anthropic` 时,Hermes 使用:
- `api_mode = anthropic_messages`
- 原生 Anthropic Messages API
- `agent/anthropic_adapter.py` 进行转换
原生 Anthropic 的凭据解析现在在两者同时存在时,优先使用可刷新的 Claude Code 凭据,而非复制的环境变量 token。实际效果为:
- 包含可刷新认证的 Claude Code 凭据文件被视为首选来源
- 手动设置的 `ANTHROPIC_TOKEN` / `CLAUDE_CODE_OAUTH_TOKEN` 值仍可作为显式覆盖
- Hermes 在调用原生 Messages API 前会预检 Anthropic 凭据刷新
- Hermes 在重建 Anthropic 客户端后,仍会在收到 401 时重试一次,作为回退路径
## OpenAI Codex 路径
Codex 使用独立的 Responses API 路径:
- `api_mode = codex_responses`
- 专用的凭据解析和认证存储支持
## 辅助模型路由
辅助任务包括:
- 视觉
- 网页提取摘要
- 上下文压缩摘要
- skills hub 操作
- MCP 辅助操作
- 记忆刷新
这些任务可以使用各自独立的 provider/模型路由,而非主对话模型。
当辅助任务配置的 provider 为 `main` 时,Hermes 通过与普通对话相同的共享运行时路径进行解析。实际效果为:
- 环境变量驱动的自定义端点仍然有效
- 通过 `hermes model` / `config.yaml` 保存的自定义端点同样有效
- 辅助路由能够区分真实保存的自定义端点与 OpenRouter 回退
## 回退模型
Hermes 支持配置回退 provider 链——一个按顺序尝试的 `(provider, model)` 条目列表,当主模型遇到错误时依次尝试。旧版单对 `fallback_model` 字典仍被接受以保持向后兼容(并在首次写入时迁移)。
### 内部工作原理
1. **存储**`AIAgent.__init__` 存储 `fallback_model` 字典并将 `_fallback_activated` 设为 `False`
2. **触发点**`_try_activate_fallback()``run_agent.py` 主重试循环的三处被调用:
- 在无效 API 响应(None choices、缺少 content)达到最大重试次数后
- 在不可重试的客户端错误(HTTP 401、403、404)时
- 在瞬时错误(HTTP 429、500、502、503)达到最大重试次数后
3. **激活流程**`_try_activate_fallback`):
- 若已激活或未配置,立即返回 `False`
- 调用 `auxiliary_client.py` 中的 `resolve_provider_client()` 构建带有正确认证的新客户端
- 确定 `api_mode`openai-codex 使用 `codex_responses`anthropic 使用 `anthropic_messages`,其余使用 `chat_completions`
- 原地替换:`self.model``self.provider``self.base_url``self.api_mode``self.client``self._client_kwargs`
- 对于 anthropic 回退:构建原生 Anthropic 客户端而非 OpenAI 兼容客户端
- 重新评估 prompt 缓存(对 OpenRouter 上的 Claude 模型启用)
-`_fallback_activated` 设为 `True`——防止再次触发
- 将重试计数重置为 0 并继续循环
4. **配置流程**
- CLI`cli.py` 读取 `CLI_CONFIG["fallback_model"]` → 传递给 `AIAgent(fallback_model=...)`
- Gateway`gateway/run.py._load_fallback_model()` 读取 `config.yaml` → 传递给 `AIAgent`
- 验证:`provider``model` 键均须非空,否则回退被禁用
### 不支持回退的场景
- **子代理委托**`tools/delegate_tool.py`):子代理继承父代理的 provider,但不继承回退配置
- **辅助任务**:使用各自独立的 provider 自动检测链(见上方辅助模型路由)
Cron 任务**支持**回退:`run_job()``config.yaml` 读取 `fallback_providers`(或旧版 `fallback_model`)并传递给 `AIAgent(fallback_model=...)`,与 gateway 的 `_load_fallback_model()` 模式一致。参见 [Cron 内部机制](./cron-internals.md)。
### 测试覆盖
参见 `tests/test_fallback_model.py`,其中包含覆盖所有支持 provider、单次触发语义及边界情况的完整测试。
## 相关文档
- [Agent 循环内部机制](./agent-loop.md)
- [ACP 内部机制](./acp-internals.md)
- [上下文压缩与 Prompt 缓存](./context-compression-and-caching.md)
@@ -0,0 +1,386 @@
# 会话存储
Hermes Agent 使用 SQLite 数据库(`~/.hermes/state.db`)跨 CLI 和 gateway 会话持久化会话元数据、完整消息历史及模型配置。这替代了早期的逐会话 JSONL 文件方案。
源文件:`hermes_state.py`
## 架构概览
```
~/.hermes/state.db (SQLite, WAL mode)
├── sessions — 会话元数据、token 计数、计费信息
├── messages — 每个会话的完整消息历史
├── messages_fts — FTS5 虚拟表(content + tool_name + tool_calls
├── messages_fts_trigram — 使用 trigram tokenizer 的 FTS5 虚拟表(CJK / 子串搜索)
├── state_meta — 键值元数据表
└── schema_version — 单行表,跟踪迁移状态
```
关键设计决策:
- **WAL 模式**:支持并发读取 + 单写入(gateway 多平台)
- **FTS5 虚拟表**:跨所有会话消息的快速全文搜索
- **会话血缘**:通过 `parent_session_id` 链实现(压缩触发的会话分割)
- **来源标记**`cli``telegram``discord` 等):用于平台过滤
- 批量运行器和 RL 轨迹不存储于此(独立系统)
## SQLite Schema
### Sessions 表
```sql
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
user_id TEXT,
model TEXT,
model_config TEXT,
system_prompt TEXT,
parent_session_id TEXT,
started_at REAL NOT NULL,
ended_at REAL,
end_reason TEXT,
message_count INTEGER DEFAULT 0,
tool_call_count INTEGER DEFAULT 0,
input_tokens INTEGER DEFAULT 0,
output_tokens INTEGER DEFAULT 0,
cache_read_tokens INTEGER DEFAULT 0,
cache_write_tokens INTEGER DEFAULT 0,
reasoning_tokens INTEGER DEFAULT 0,
billing_provider TEXT,
billing_base_url TEXT,
billing_mode TEXT,
estimated_cost_usd REAL,
actual_cost_usd REAL,
cost_status TEXT,
cost_source TEXT,
pricing_version TEXT,
title TEXT,
api_call_count INTEGER DEFAULT 0,
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
);
CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions(source);
CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id);
CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_title_unique
ON sessions(title) WHERE title IS NOT NULL;
```
### Messages 表
```sql
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL REFERENCES sessions(id),
role TEXT NOT NULL,
content TEXT,
tool_call_id TEXT,
tool_calls TEXT,
tool_name TEXT,
timestamp REAL NOT NULL,
token_count INTEGER,
finish_reason TEXT,
reasoning TEXT,
reasoning_content TEXT,
reasoning_details TEXT,
codex_reasoning_items TEXT,
codex_message_items TEXT
);
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, timestamp);
```
说明:
- `tool_calls` 以 JSON 字符串存储(序列化的 tool call 对象列表)
- `reasoning_details``codex_reasoning_items``codex_message_items` 以 JSON 字符串存储
- `reasoning` 存储提供商暴露的原始推理文本
- 时间戳为 Unix epoch 浮点数(`time.time()`
### FTS5 全文搜索
```sql
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
content,
content=messages,
content_rowid=id
);
```
FTS5 表通过三个触发器与 `messages` 表保持同步,分别在 INSERT、UPDATE 和 DELETE 时触发:
```sql
CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, content)
VALUES('delete', old.id, old.content);
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, content)
VALUES('delete', old.id, old.content);
INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
END;
```
## Schema 版本与迁移
当前 schema 版本:**11**
`schema_version` 表存储单个整数。简单的列添加由 `_reconcile_columns()` 声明式处理(对比实时列与 `SCHEMA_SQL` 并 ADD 缺失列)。版本门控链保留用于无法声明式表达的数据迁移及索引/FTS 变更:
| 版本 | 变更 |
|------|------|
| 1 | 初始 schemasessions、messages、FTS5 |
| 2 | 向 messages 添加 `finish_reason` 列 |
| 3 | 向 sessions 添加 `title` 列 |
| 4 | 在 `title` 上添加唯一索引(允许 NULL,非 NULL 必须唯一) |
| 5 | 添加计费列:`cache_read_tokens``cache_write_tokens``reasoning_tokens``billing_provider``billing_base_url``billing_mode``estimated_cost_usd``actual_cost_usd``cost_status``cost_source``pricing_version` |
| 6 | 向 messages 添加推理列:`reasoning``reasoning_details``codex_reasoning_items` |
| 7 | 向 messages 添加 `reasoning_content` 列 |
| 8 | 向 sessions 添加 `api_call_count` 列 |
| 9 | 向 messages 添加 `codex_message_items` 列,用于 Codex Responses 消息 id/phase 重放 |
| 10 | 添加 `messages_fts_trigram` 虚拟表(trigram tokenizer,用于 CJK / 子串搜索)并回填现有行 |
| 11 | 重新索引 `messages_fts``messages_fts_trigram` 以覆盖 `tool_name` + `tool_calls`,从外部内容模式切换为内联模式;删除旧触发器并回填所有消息行 |
声明式列添加使用 `ALTER TABLE ADD COLUMN`,包裹在 try/except 中以处理列已存在的情况(幂等)。每个成功的迁移块完成后版本号递增。
## 写入竞争处理
多个 hermes 进程(gateway + CLI 会话 + worktree agent)共享同一个 `state.db``SessionDB` 类通过以下方式处理写入竞争:
- **短 SQLite 超时**1 秒),而非默认的 30 秒
- **应用层重试**,带随机抖动(20–150ms,最多 15 次重试)
- **BEGIN IMMEDIATE** 事务,在事务开始时暴露锁竞争
- **定期 WAL checkpoint**,每 50 次成功写入执行一次(PASSIVE 模式)
这避免了"护卫效应"——SQLite 确定性内部退避会导致所有竞争写入者在相同间隔重试。
```
_WRITE_MAX_RETRIES = 15
_WRITE_RETRY_MIN_S = 0.020 # 20ms
_WRITE_RETRY_MAX_S = 0.150 # 150ms
_CHECKPOINT_EVERY_N_WRITES = 50
```
## 常用操作
### 初始化
```python
from hermes_state import SessionDB
db = SessionDB() # 默认:~/.hermes/state.db
db = SessionDB(db_path=Path("/tmp/test.db")) # 自定义路径
```
### 创建和管理会话
```python
# 创建新会话
db.create_session(
session_id="sess_abc123",
source="cli",
model="anthropic/claude-sonnet-4.6",
user_id="user_1",
parent_session_id=None, # 或用于血缘追踪的上一个会话 ID
)
# 结束会话
db.end_session("sess_abc123", end_reason="user_exit")
# 重新打开会话(清除 ended_at/end_reason
db.reopen_session("sess_abc123")
```
### 存储消息
```python
msg_id = db.append_message(
session_id="sess_abc123",
role="assistant",
content="Here's the answer...",
tool_calls=[{"id": "call_1", "function": {"name": "terminal", "arguments": "{}"}}],
token_count=150,
finish_reason="stop",
reasoning="Let me think about this...",
)
```
### 检索消息
```python
# 包含所有元数据的原始消息
messages = db.get_messages("sess_abc123")
# OpenAI 对话格式(用于 API 重放)
conversation = db.get_messages_as_conversation("sess_abc123")
# 返回:[{"role": "user", "content": "..."}, {"role": "assistant", ...}]
```
### 会话标题
```python
# 设置标题(非 NULL 标题中必须唯一)
db.set_session_title("sess_abc123", "Fix Docker Build")
# 按标题解析(返回血缘中最新的)
session_id = db.resolve_session_by_title("Fix Docker Build")
# 自动生成血缘中的下一个标题
next_title = db.get_next_title_in_lineage("Fix Docker Build")
# 返回:"Fix Docker Build #2"
```
## 全文搜索
`search_messages()` 方法支持 FTS5 查询语法,并自动对用户输入进行清理。
### 基本搜索
```python
results = db.search_messages("docker deployment")
```
### FTS5 查询语法
| 语法 | 示例 | 含义 |
|------|------|------|
| 关键词 | `docker deployment` | 两个词均包含(隐式 AND) |
| 引号短语 | `"exact phrase"` | 精确短语匹配 |
| 布尔 OR | `docker OR kubernetes` | 任一词 |
| 布尔 NOT | `python NOT java` | 排除词 |
| 前缀 | `deploy*` | 前缀匹配 |
### 过滤搜索
```python
# 仅搜索 CLI 会话
results = db.search_messages("error", source_filter=["cli"])
# 排除 gateway 会话
results = db.search_messages("bug", exclude_sources=["telegram", "discord"])
# 仅搜索用户消息
results = db.search_messages("help", role_filter=["user"])
```
### 搜索结果格式
每条结果包含:
- `id``session_id``role``timestamp`
- `snippet` — FTS5 生成的片段,带 `>>>match<<<` 标记
- `context` — 匹配前后各 1 条消息(内容截断至 200 字符)
- `source``model``session_started` — 来自父会话
`_sanitize_fts5_query()` 方法处理边缘情况:
- 去除不匹配的引号和特殊字符
- 将含连字符的词包裹在引号中(`chat-send``"chat-send"`
- 移除悬空的布尔运算符(`hello AND``hello`
## 会话血缘
会话可通过 `parent_session_id` 形成链。这发生在 gateway 中上下文压缩触发会话分割时。
### 查询:查找会话血缘
```sql
-- 查找会话的所有祖先
WITH RECURSIVE lineage AS (
SELECT * FROM sessions WHERE id = ?
UNION ALL
SELECT s.* FROM sessions s
JOIN lineage l ON s.id = l.parent_session_id
)
SELECT id, title, started_at, parent_session_id FROM lineage;
-- 查找会话的所有后代
WITH RECURSIVE descendants AS (
SELECT * FROM sessions WHERE id = ?
UNION ALL
SELECT s.* FROM sessions s
JOIN descendants d ON s.parent_session_id = d.id
)
SELECT id, title, started_at FROM descendants;
```
### 查询:带预览的最近会话
```sql
SELECT s.*,
COALESCE(
(SELECT SUBSTR(m.content, 1, 63)
FROM messages m
WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
ORDER BY m.timestamp, m.id LIMIT 1),
''
) AS preview,
COALESCE(
(SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id),
s.started_at
) AS last_active
FROM sessions s
ORDER BY s.started_at DESC
LIMIT 20;
```
### 查询:Token 使用统计
```sql
-- 按模型统计总 token 数
SELECT model,
COUNT(*) as session_count,
SUM(input_tokens) as total_input,
SUM(output_tokens) as total_output,
SUM(estimated_cost_usd) as total_cost
FROM sessions
WHERE model IS NOT NULL
GROUP BY model
ORDER BY total_cost DESC;
-- token 使用量最高的会话
SELECT id, title, model, input_tokens + output_tokens AS total_tokens,
estimated_cost_usd
FROM sessions
ORDER BY total_tokens DESC
LIMIT 10;
```
## 导出与清理
```python
# 导出单个会话及其消息
data = db.export_session("sess_abc123")
# 导出所有会话(含消息)为字典列表
all_data = db.export_all(source="cli")
# 删除旧会话(仅删除已结束的会话)
deleted_count = db.prune_sessions(older_than_days=90)
deleted_count = db.prune_sessions(older_than_days=30, source="telegram")
# 清除消息但保留会话记录
db.clear_messages("sess_abc123")
# 删除会话及所有消息
db.delete_session("sess_abc123")
```
## 数据库位置
默认路径:`~/.hermes/state.db`
该路径由 `hermes_constants.get_hermes_home()` 推导,默认解析为 `~/.hermes/`,或 `HERMES_HOME` 环境变量的值。
数据库文件、WAL 文件(`state.db-wal`)和共享内存文件(`state.db-shm`)均创建于同一目录。
@@ -0,0 +1,233 @@
---
sidebar_position: 9
title: "工具运行时"
description: "工具注册表、toolset、调度及终端环境的运行时行为"
---
# 工具运行时
Hermes 工具是自注册函数,按 toolset(工具集)分组,并通过中央注册表/调度系统执行。
主要文件:
- `tools/registry.py`
- `model_tools.py`
- `toolsets.py`
- `tools/terminal_tool.py`
- `tools/environments/*`
## 工具注册模型
每个工具模块在导入时调用 `registry.register(...)`
`model_tools.py` 负责导入/发现工具模块,并构建供模型使用的 schema 列表。
### `registry.register()` 的工作原理
`tools/` 中的每个工具文件在模块级别调用 `registry.register()` 来声明自身。函数签名如下:
```python
registry.register(
name="terminal", # 唯一工具名称(用于 API schema)
toolset="terminal", # 该工具所属的 toolset
schema={...}, # OpenAI function-calling schema(描述、参数)
handler=handle_terminal, # 工具被调用时执行的函数
check_fn=check_terminal, # 可选:返回 True/False 表示是否可用
requires_env=["SOME_VAR"], # 可选:所需的环境变量(用于 UI 显示)
is_async=False, # handler 是否为异步协程
description="Run commands", # 人类可读的描述
emoji="💻", # 用于 spinner/进度显示的 emoji
)
```
每次调用都会创建一个 `ToolEntry`,以工具名称为键存储在单例 `ToolRegistry._tools` 字典中。若不同 toolset 之间出现名称冲突,会记录警告,后注册的条目覆盖前者。
### 发现机制:`discover_builtin_tools()`
`model_tools.py` 被导入时,会调用 `tools/registry.py` 中的 `discover_builtin_tools()`。该函数使用 AST 解析扫描所有 `tools/*.py` 文件,找出包含顶层 `registry.register()` 调用的模块,然后导入它们:
```python
# tools/registry.py(简化版)
def discover_builtin_tools(tools_dir=None):
tools_path = Path(tools_dir) if tools_dir else Path(__file__).parent
for path in sorted(tools_path.glob("*.py")):
if path.name in {"__init__.py", "registry.py", "mcp_tool.py"}:
continue
if _module_registers_tools(path): # AST 检查顶层 registry.register()
importlib.import_module(f"tools.{path.stem}")
```
这种自动发现机制意味着新工具文件会被自动识别——无需手动维护列表。AST 检查只匹配顶层的 `registry.register()` 调用(不匹配函数内部的调用),因此 `tools/` 中的辅助模块不会被导入。
每次导入都会触发模块的 `registry.register()` 调用。可选工具中的错误(例如图像生成工具缺少 `fal_client`)会被捕获并记录——不会阻止其他工具加载。
核心工具发现完成后,还会发现 MCP 工具和插件工具:
1. **MCP 工具**`tools.mcp_tool.discover_mcp_tools()` 读取 MCP 服务器配置,并注册来自外部服务器的工具。
2. **插件工具**`hermes_cli.plugins.discover_plugins()` 加载用户/项目/pip 插件,这些插件可能注册额外的工具。
## 工具可用性检查(`check_fn`
每个工具可以选择性地提供一个 `check_fn`——一个可调用对象,在工具可用时返回 `True`,否则返回 `False`。典型的检查包括:
- **API 密钥是否存在** — 例如,`lambda: bool(os.environ.get("SERP_API_KEY"))` 用于网络搜索
- **服务是否运行** — 例如,检查 Honcho 服务器是否已配置
- **二进制文件是否已安装** — 例如,验证浏览器工具的 `playwright` 是否可用
`registry.get_definitions()` 为模型构建 schema 列表时,会运行每个工具的 `check_fn()`
```python
# 简化自 registry.py
if entry.check_fn:
try:
available = bool(entry.check_fn())
except Exception:
available = False # 异常 = 不可用
if not available:
continue # 完全跳过该工具
```
关键行为:
- 检查结果**按调用缓存**——若多个工具共享同一个 `check_fn`,只运行一次。
- `check_fn()` 中的异常被视为"不可用"(故障安全)。
- `is_toolset_available()` 方法检查某个 toolset 的 `check_fn` 是否通过,用于 UI 显示和 toolset 解析。
## Toolset 解析
Toolset 是工具的命名集合。Hermes 通过以下方式解析它们:
- 显式启用/禁用的 toolset 列表
- 平台预设(`hermes-cli``hermes-telegram` 等)
- 动态 MCP toolset
- 精选的特殊用途集合,如 `hermes-acp`
### `get_tool_definitions()` 如何过滤工具
主入口点为 `model_tools.get_tool_definitions(enabled_toolsets, disabled_toolsets, quiet_mode)`
1. **若提供了 `enabled_toolsets`** — 仅包含这些 toolset 中的工具。每个 toolset 名称通过 `resolve_toolset()` 解析,将复合 toolset 展开为单个工具名称。
2. **若提供了 `disabled_toolsets`** — 从所有 toolset 开始,减去已禁用的。
3. **若两者均未提供** — 包含所有已知 toolset。
4. **注册表过滤** — 解析后的工具名称集合传递给 `registry.get_definitions()`,后者应用 `check_fn` 过滤并返回 OpenAI 格式的 schema。
5. **动态 schema 修补** — 过滤后,`execute_code``browser_navigate` 的 schema 会被动态调整,仅引用实际通过过滤的工具(防止模型幻觉出不可用的工具)。
### 旧版 toolset 名称
带有 `_tools` 后缀的旧版 toolset 名称(例如 `web_tools``terminal_tools`)通过 `_LEGACY_TOOLSET_MAP` 映射到其现代工具名称,以保持向后兼容性。
## 调度
运行时,工具通过中央注册表调度,但部分 agent 级别的工具(如 memory/todo/session-search 处理)由 agent 循环直接处理。
### 调度流程:模型 tool_call → handler 执行
当模型返回 `tool_call` 时,流程如下:
```
模型响应包含 tool_call
run_agent.py agent 循环
model_tools.handle_function_call(name, args, task_id, user_task)
[Agent 循环工具?] → 由 agent 循环直接处理(todo、memory、session_search、delegate_task
[插件 pre-hook] → invoke_hook("pre_tool_call", ...)
registry.dispatch(name, args, **kwargs)
按名称查找 ToolEntry
[异步 handler] → 通过 _run_async() 桥接
[同步 handler] → 直接调用
返回结果字符串(或 JSON 错误)
[插件 post-hook] → invoke_hook("post_tool_call", ...)
```
### 错误包装
所有工具执行在两个层级进行错误处理:
1. **`registry.dispatch()`** — 捕获 handler 抛出的任何异常,并以 JSON 形式返回 `{"error": "Tool execution failed: ExceptionType: message"}`
2. **`handle_function_call()`** — 将整个调度包裹在次级 try/except 中,返回 `{"error": "Error executing tool_name: message"}`
这确保模型始终收到格式正确的 JSON 字符串,而不会遇到未处理的异常。
### Agent 循环工具
以下四个工具在注册表调度之前被拦截,因为它们需要 agent 级别的状态(TodoStore、MemoryStore 等):
- `todo` — 规划/任务跟踪
- `memory` — 持久化 memory 写入
- `session_search` — 跨会话召回
- `delegate_task` — 生成子 agent 会话
这些工具的 schema 仍在注册表中注册(供 `get_tool_definitions` 使用),但若调度以某种方式直接到达它们,其 handler 会返回一个存根错误。
### 异步桥接
当工具 handler 为异步时,`_run_async()` 将其桥接到同步调度路径:
- **CLI 路径(无运行中的事件循环)** — 使用持久化事件循环以保持缓存的异步客户端存活
- **Gateway 路径(有运行中的事件循环)** — 使用 `asyncio.run()` 启动一个一次性线程
- **工作线程(并行工具)** — 使用存储在线程本地存储中的每线程持久化循环
## DANGEROUS_PATTERNS 审批流程
终端工具集成了定义在 `tools/approval.py` 中的危险命令审批系统:
1. **模式检测**`DANGEROUS_PATTERNS` 是一个 `(regex, description)` 元组列表,涵盖破坏性操作:
- 递归删除(`rm -rf`
- 文件系统格式化(`mkfs``dd`
- SQL 破坏性操作(`DROP TABLE`、不带 `WHERE``DELETE FROM`
- 系统配置覆写(`> /etc/`
- 服务操控(`systemctl stop`
- 远程代码执行(`curl | sh`
- Fork bomb、进程终止等
2. **检测** — 在执行任何终端命令之前,`detect_dangerous_command(command)` 会对所有模式进行检查。
3. **审批提示** — 若发现匹配:
- **CLI 模式** — 交互式提示要求用户批准、拒绝或永久允许
- **Gateway 模式** — 异步审批回调将请求发送至消息平台
- **智能审批** — 可选地,辅助 LLM 可自动批准匹配模式但风险较低的命令(例如,`rm -rf node_modules/` 是安全的,但匹配"递归删除"模式)
4. **会话状态** — 审批按会话跟踪。一旦在某个会话中批准了"递归删除",后续的 `rm -rf` 命令不会再次提示。
5. **永久允许列表** — "永久允许"选项会将该模式写入 `config.yaml``command_allowlist`,跨会话持久化。
## 终端/运行时环境
终端系统支持多种后端:
- local
- docker
- ssh
- singularity
- modal
- daytona
还支持:
- 按任务的 cwd 覆盖
- 后台进程管理
- PTY 模式
- 危险命令的审批回调
## 并发
工具调用可以顺序执行,也可以并发执行,具体取决于工具组合和交互需求。
## 相关文档
- [Toolsets 参考](../reference/toolsets-reference.md)
- [内置工具参考](../reference/tools-reference.md)
- [Agent 循环内部机制](./agent-loop.md)
- [ACP 内部机制](./acp-internals.md)
@@ -0,0 +1,222 @@
# 轨迹格式
Hermes Agent 以 ShareGPT 兼容的 JSONL 格式保存对话轨迹,用于训练数据、调试产物和强化学习数据集。
源文件:`agent/trajectory.py``run_agent.py`(搜索 `_save_trajectory`)、`batch_runner.py`
## 文件命名规范
轨迹写入当前工作目录下的文件:
| 文件 | 时机 |
|------|------|
| `trajectory_samples.jsonl` | 成功完成的对话(`completed=True` |
| `failed_trajectories.jsonl` | 失败或被中断的对话(`completed=False` |
批量运行器(`batch_runner.py`)按批次写入自定义输出文件
(例如 `batch_001_output.jsonl`),并附带额外的元数据字段。
可通过 `save_trajectory()``filename` 参数覆盖文件名。
## JSONL 条目格式
文件中每一行是一个独立的 JSON 对象。共有两种变体:
### CLI/交互式格式(来自 `_save_trajectory`
```json
{
"conversations": [ ... ],
"timestamp": "2026-03-30T14:22:31.456789",
"model": "anthropic/claude-sonnet-4.6",
"completed": true
}
```
### 批量运行器格式(来自 `batch_runner.py`
```json
{
"prompt_index": 42,
"conversations": [ ... ],
"metadata": { "prompt_source": "gsm8k", "difficulty": "hard" },
"completed": true,
"partial": false,
"api_calls": 7,
"toolsets_used": ["code_tools", "file_tools"],
"tool_stats": {
"terminal": {"count": 3, "success": 3, "failure": 0},
"read_file": {"count": 2, "success": 2, "failure": 0},
"write_file": {"count": 0, "success": 0, "failure": 0}
},
"tool_error_counts": {
"terminal": 0,
"read_file": 0,
"write_file": 0
}
}
```
`tool_stats``tool_error_counts` 字典已规范化,包含所有可能的工具
(来自 `model_tools.TOOL_TO_TOOLSET_MAP`),缺省值为零,
确保各条目的 schema 一致,便于 HuggingFace 数据集加载。
## conversations 数组(ShareGPT 格式)
`conversations` 数组使用 ShareGPT 角色约定:
| API 角色 | ShareGPT `from` |
|----------|-----------------|
| system | `"system"` |
| user | `"human"` |
| assistant | `"gpt"` |
| tool | `"tool"` |
### 完整示例
```json
{
"conversations": [
{
"from": "system",
"value": "You are a function calling AI model. You are provided with function signatures within <tools> </tools> XML tags. You may call one or more functions to assist with the user query. If available tools are not relevant in assisting with user query, just respond in natural conversational language. Don't make assumptions about what values to plug into functions. After calling & executing the functions, you will be provided with function results within <tool_response> </tool_response> XML tags. Here are the available tools:\n<tools>\n[{\"name\": \"terminal\", \"description\": \"Execute shell commands\", \"parameters\": {\"type\": \"object\", \"properties\": {\"command\": {\"type\": \"string\"}}}, \"required\": null}]\n</tools>\nFor each function call return a JSON object, with the following pydantic model json schema for each:\n{'title': 'FunctionCall', 'type': 'object', 'properties': {'name': {'title': 'Name', 'type': 'string'}, 'arguments': {'title': 'Arguments', 'type': 'object'}}, 'required': ['name', 'arguments']}\nEach function call should be enclosed within <tool_call> </tool_call> XML tags.\nExample:\n<tool_call>\n{'name': <function-name>,'arguments': <args-dict>}\n</tool_call>"
},
{
"from": "human",
"value": "What Python version is installed?"
},
{
"from": "gpt",
"value": "<think>\nThe user wants to know the Python version. I should run python3 --version.\n</think>\n<tool_call>\n{\"name\": \"terminal\", \"arguments\": {\"command\": \"python3 --version\"}}\n</tool_call>"
},
{
"from": "tool",
"value": "<tool_response>\n{\"tool_call_id\": \"call_abc123\", \"name\": \"terminal\", \"content\": \"Python 3.11.6\"}\n</tool_response>"
},
{
"from": "gpt",
"value": "<think>\nGot the version. I can now answer the user.\n</think>\nPython 3.11.6 is installed on this system."
}
],
"timestamp": "2026-03-30T14:22:31.456789",
"model": "anthropic/claude-sonnet-4.6",
"completed": true
}
```
## 规范化规则
### 推理内容标记
轨迹转换器将所有推理内容统一规范化为 `<think>` 标签,无论模型最初以何种方式生成:
1. **原生思考 token**(来自 Anthropic、OpenAI o 系列等提供商的 `msg["reasoning"]` 字段):
包装为 `<think>\n{reasoning}\n</think>\n` 并置于内容之前。
2. **REASONING_SCRATCHPAD XML**(禁用原生思考时,模型通过系统提示指令的 XML 进行推理):
`<REASONING_SCRATCHPAD>` 标签通过 `convert_scratchpad_to_think()` 转换为 `<think>`
3. **空 think 块**:每个 `gpt` 轮次都保证包含一个 `<think>` 块。若未产生任何推理内容,
则插入空块:`<think>\n</think>\n`——确保训练数据格式一致。
### 工具调用规范化
API 格式的工具调用(含 `tool_call_id`、函数名、JSON 字符串形式的参数)
转换为 XML 包裹的 JSON
```
<tool_call>
{"name": "terminal", "arguments": {"command": "ls -la"}}
</tool_call>
```
- 参数从 JSON 字符串解析回对象(不进行二次编码)
- 若 JSON 解析失败(正常情况下不应发生——对话期间已验证),
则使用空 `{}` 并记录警告日志
- 一个助手轮次中的多个工具调用,在单条 `gpt` 消息中生成多个 `<tool_call>`
### 工具响应规范化
跟随助手消息的所有工具结果,合并为单条 `tool` 轮次,以 XML 包裹的 JSON 响应呈现:
```
<tool_response>
{"tool_call_id": "call_abc123", "name": "terminal", "content": "output here"}
</tool_response>
```
- 若工具内容看起来像 JSON(以 `{``[` 开头),则解析后 content 字段包含 JSON 对象/数组,而非字符串
- 多个工具结果以换行符连接,合并为一条消息
- 工具名称按位置与父助手消息的 `tool_calls` 数组匹配
### 系统消息
系统消息在保存时生成(不取自对话内容),遵循 Hermes 函数调用 prompt 模板,包含:
- 说明函数调用协议的前言
- 包含 JSON 工具定义的 `<tools>` XML 块
- `FunctionCall` 对象的 schema 参考
- `<tool_call>` 示例
工具定义包含 `name``description``parameters``required`
(设为 `null` 以匹配规范格式)。
## 加载轨迹
轨迹为标准 JSONL 格式——可用任意 JSON lines 读取器加载:
```python
import json
def load_trajectories(path: str):
"""Load trajectory entries from a JSONL file."""
entries = []
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
entries.append(json.loads(line))
return entries
# Filter to successful completions only
successful = [e for e in load_trajectories("trajectory_samples.jsonl")
if e.get("completed")]
# Extract just the conversations for training
training_data = [e["conversations"] for e in successful]
```
### 加载至 HuggingFace Datasets
```python
from datasets import load_dataset
ds = load_dataset("json", data_files="trajectory_samples.jsonl")
```
规范化的 `tool_stats` schema 确保所有条目具有相同的列,
防止数据集加载时出现 Arrow schema 不匹配错误。
## 控制轨迹保存
在 CLI 中,轨迹保存通过以下方式控制:
```yaml
# config.yaml
agent:
save_trajectories: true # default: false
```
或通过 `--save-trajectories` 标志。当 agent 以 `save_trajectories=True` 初始化时,
`_save_trajectory()` 方法在每次对话轮次结束时调用。
批量运行器始终保存轨迹(这是其主要用途)。
所有轮次中推理内容为零的样本,将被批量运行器自动丢弃,
以避免非推理示例污染训练数据。
@@ -0,0 +1,231 @@
---
sidebar_position: 12
title: "视频生成 Provider 插件"
description: "如何为 Hermes Agent 构建视频生成后端插件"
---
# 构建视频生成 Provider 插件
视频生成 provider 插件注册一个后端,用于处理所有 `video_generate` 工具调用。内置 provider(xAI、FAL)以插件形式提供。将目录放入 `plugins/video_gen/<name>/` 即可添加新 provider 或覆盖内置 provider。
:::tip
视频生成与[图像生成 Provider 插件](/developer-guide/image-gen-provider-plugin)几乎一一对应——如果你已构建过图像生成后端,对其结构应已了然于胸。主要区别在于:`capabilities()` 方法用于声明模态(modality)/宽高比/时长,以及路由约定(传入 `image_url` 则使用图生视频,省略则使用文生视频——provider 在内部选择正确的端点)。
:::
## 统一接口(一个工具,两种模态)
`video_generate` 工具通过一个参数暴露两种模态:
- **文生视频(Text-to-video** — 仅传入 `prompt`。Provider 路由至其文生视频端点。
- **图生视频(Image-to-video** — 同时传入 `prompt``image_url`。Provider 路由至其图生视频端点。
编辑和扩展功能有意不在支持范围内。大多数后端不支持这些功能,且不一致性会迫使 agent 的工具描述中出现针对各后端的说明文字。
## 发现机制
Hermes 在三个位置扫描视频生成后端:
1. **内置**`<repo>/plugins/video_gen/<name>/`(通过 `kind: backend` 自动加载)
2. **用户**`~/.hermes/plugins/video_gen/<name>/`(通过 `plugins.enabled` 选择启用)
3. **Pip** — 声明了 `hermes_agent.plugins` 入口点的包
每个插件的 `register(ctx)` 函数调用 `ctx.register_video_gen_provider(...)`。活跃 provider 由 `config.yaml` 中的 `video_gen.provider` 指定;`hermes tools` → Video Generation 引导用户完成选择。与 `image_generate` 不同,此处没有内置的遗留后端——每个 provider 都是插件。
## 目录结构
```
plugins/video_gen/my-backend/
├── __init__.py # VideoGenProvider 子类 + register()
└── plugin.yaml # 包含 kind: backend 的清单文件
```
## VideoGenProvider ABC
继承 `agent.video_gen_provider.VideoGenProvider`。必须实现:`name` 属性和 `generate()` 方法。
```python
# plugins/video_gen/my-backend/__init__.py
from typing import Any, Dict, List, Optional
import os
from agent.video_gen_provider import (
VideoGenProvider,
error_response,
success_response,
)
class MyVideoGenProvider(VideoGenProvider):
@property
def name(self) -> str:
return "my-backend"
@property
def display_name(self) -> str:
return "My Backend"
def is_available(self) -> bool:
return bool(os.environ.get("MY_API_KEY"))
def list_models(self) -> List[Dict[str, Any]]:
# Each entry is a model FAMILY — a name the user picks once.
# Your provider's generate() routes within the family based on
# whether image_url was passed.
return [
{
"id": "fast",
"display": "Fast",
"speed": "~30s",
"strengths": "Cheapest tier",
"price": "$0.05/s",
"modalities": ["text", "image"], # advisory
},
]
def default_model(self) -> Optional[str]:
return "fast"
def capabilities(self) -> Dict[str, Any]:
return {
"modalities": ["text", "image"],
"aspect_ratios": ["16:9", "9:16"],
"resolutions": ["720p", "1080p"],
"min_duration": 1,
"max_duration": 10,
"supports_audio": False,
"supports_negative_prompt": True,
"max_reference_images": 0,
}
def get_setup_schema(self) -> Dict[str, Any]:
return {
"name": "My Backend",
"badge": "paid",
"tag": "Short description shown in `hermes tools`",
"env_vars": [
{
"key": "MY_API_KEY",
"prompt": "My Backend API key",
"url": "https://mybackend.example.com/keys",
},
],
}
def generate(
self,
prompt: str,
*,
model: Optional[str] = None,
image_url: Optional[str] = None,
reference_image_urls: Optional[List[str]] = None,
duration: Optional[int] = None,
aspect_ratio: str = "16:9",
resolution: str = "720p",
negative_prompt: Optional[str] = None,
audio: Optional[bool] = None,
seed: Optional[int] = None,
**kwargs: Any, # always ignore unknown kwargs for forward-compat
) -> Dict[str, Any]:
# ROUTE: image_url presence picks the endpoint.
if image_url:
endpoint = "my-backend/image-to-video"
modality_used = "image"
else:
endpoint = "my-backend/text-to-video"
modality_used = "text"
# ... call your API ...
return success_response(
video="https://your-cdn/output.mp4",
model=model or "fast",
prompt=prompt,
modality=modality_used,
aspect_ratio=aspect_ratio,
duration=duration or 5,
provider=self.name,
)
def register(ctx) -> None:
ctx.register_video_gen_provider(MyVideoGenProvider())
```
## 插件清单
```yaml
# plugins/video_gen/my-backend/plugin.yaml
name: my-backend
version: 1.0.0
description: "My video generation backend"
author: Your Name
kind: backend
requires_env:
- MY_API_KEY
```
## `video_generate` 参数模式
该工具在所有后端中使用统一的参数模式。Provider 忽略其不支持的参数。
| 参数 | 说明 |
|---|---|
| `prompt` | 文本指令(必填) |
| `image_url` | 设置时 → 图生视频;省略时 → 文生视频 |
| `reference_image_urls` | 风格/角色参考图(取决于 provider) |
| `duration` | 秒数——provider 会进行截断 |
| `aspect_ratio` | `"16:9"``"9:16"``"1:1"` 等——provider 会进行截断 |
| `resolution` | `"480p"` / `"540p"` / `"720p"` / `"1080p"`——provider 会进行截断 |
| `negative_prompt` | 需要避免的内容(仅 Pixverse/Kling 支持) |
| `audio` | 原生音频(Veo3 / Pixverse 定价层级) |
| `seed` | 可复现性 |
| `model` | 覆盖当前活跃的模型/系列 |
Provider 的 `capabilities()` 声明上述哪些参数会被实际处理。Agent 在工具描述中看到的是当前活跃后端的能力信息,当用户通过 `hermes tools` 切换后端时会动态重建。
## 模型系列与端点路由(FAL 模式)
当你的后端每个"模型"对应多个端点时——例如 FAL,其中每个系列(Veo 3.1、Pixverse v6、Kling O3)都有 `/text-to-video``/image-to-video` 两个 URL——将每个**系列**表示为一个目录条目。你的 `generate()` 根据是否传入 `image_url` 来选择正确的端点:
```python
FAMILIES = {
"veo3.1": {
"text_endpoint": "fal-ai/veo3.1",
"image_endpoint": "fal-ai/veo3.1/image-to-video",
# ... family-specific capability flags ...
},
}
def generate(self, prompt, *, image_url=None, model=None, **kwargs):
family_id, family = _resolve_family(model)
endpoint = family["image_endpoint"] if image_url else family["text_endpoint"]
# ... build payload from family's declared capability flags, call endpoint ...
```
用户在 `hermes tools` 中只需选择一次 `veo3.1`。Agent 无需关心端点——它只负责传入(或不传入)`image_url`
## 选择优先级
针对每个实例的模型配置(参见 `plugins/video_gen/fal/__init__.py`):
1. 工具调用中的 `model=` 关键字参数
2. `<PROVIDER>_VIDEO_MODEL` 环境变量
3. `config.yaml` 中的 `video_gen.<provider>.model`
4. `config.yaml` 中的 `video_gen.model`(当其值为你的某个 ID 时)
5. Provider 的 `default_model()`
## 响应结构
`success_response()``error_response()` 生成每个后端返回的标准 dict 结构。请使用它们——不要手动构造 dict。
成功响应的键:`success``video`URL 或绝对路径)、`model``prompt``modality``"text"``"image"`)、`aspect_ratio``duration``provider`,以及 `extra`
错误响应的键:`success``video`None)、`error``error_type``model``prompt``aspect_ratio``provider`
## 产物保存位置
如果你的后端返回 base64 数据,使用 `save_b64_video()` 将其写入 `$HERMES_HOME/cache/videos/`。对于通过后续 HTTP 请求获取的原始字节,使用 `save_bytes_video()`。否则直接返回上游 URL——gateway 在交付时会解析远程 URL。
## 测试
`tests/plugins/video_gen/test_<name>_plugin.py` 下添加冒烟测试。xAI 和 FAL 的测试展示了标准模式——注册、验证目录、分别在传入和不传入 `image_url` 的情况下测试路由,并断言在缺少认证时返回干净的错误响应。
@@ -0,0 +1,260 @@
---
sidebar_position: 12
title: "网页搜索提供商插件"
description: "如何为 Hermes Agent 构建网页搜索/提取/爬取后端插件"
---
# 构建网页搜索提供商插件
网页搜索提供商插件注册一个后端,用于处理 `web_search``web_extract` 以及(可选的)深度爬取工具调用。内置提供商——Firecrawl、SearXNG、Tavily、Exa、Parallel、Brave Search(免费层)和 DDGS——均以插件形式存放于 `plugins/web/<name>/` 目录下。你可以在该目录旁新建一个目录来添加新提供商,或覆盖已有的内置提供商。
:::tip
网页搜索是 Hermes 支持的多种**后端插件**之一。其他插件(各有其 ABC)包括:[图像生成提供商插件](/developer-guide/image-gen-provider-plugin)、[视频生成提供商插件](/developer-guide/video-gen-provider-plugin)、[记忆提供商插件](/developer-guide/memory-provider-plugin)、[上下文引擎插件](/developer-guide/context-engine-plugin)和[模型提供商插件](/developer-guide/model-provider-plugin)。通用工具/hook/CLI 插件请参阅[构建 Hermes 插件](/guides/build-a-hermes-plugin)。
:::
## 发现机制
Hermes 在三个位置扫描网页搜索后端:
1. **内置**`<repo>/plugins/web/<name>/`(以 `kind: backend` 自动加载,始终可用)
2. **用户**`~/.hermes/plugins/web/<name>/`(通过 `plugins.enabled``hermes plugins enable <name>` 按需启用)
3. **Pip** — 声明了 `hermes_agent.plugins` 入口点的包
每个插件的 `register(ctx)` 函数调用 `ctx.register_web_search_provider(...)` ——将实例注册到 `agent/web_search_registry.py` 中的注册表。各能力的活跃提供商由配置决定:
| 能力 | 配置键 | 回退至 |
|---|---|---|
| `web_search` | `web.search_backend` | `web.backend` |
| `web_extract` | `web.extract_backend` | `web.backend` |
| `web_extract` 内的深度爬取模式 | `web.extract_backend` | `web.backend` |
若两个键均未设置,Hermes 将根据环境中存在的 API key/URL 自动检测后端。`hermes tools` 会引导用户完成选择。
## 目录结构
```
plugins/web/my-backend/
├── __init__.py # register() 入口点
├── provider.py # WebSearchProvider 子类
└── plugin.yaml # 包含 kind: backend 和 provides_web_providers 的清单文件
```
`brave_free/``ddgs/` 是代码库中最小的参考实现——`brave_free` 是需要 API key 的纯搜索提供商,`ddgs` 是无需 key 且懒加载 SDK 的提供商。
## WebSearchProvider ABC
继承 `agent.web_search_provider.WebSearchProvider`。唯一必须实现的成员是 `name``is_available()`,以及你所实现的 `search()` / `extract()` / `crawl()` 中的相应方法。
```python
# plugins/web/my-backend/provider.py
from __future__ import annotations
import os
from typing import Any, Dict, List
from agent.web_search_provider import WebSearchProvider
class MyBackendWebSearchProvider(WebSearchProvider):
"""Minimal search-only provider against the My Backend HTTP API."""
@property
def name(self) -> str:
# Stable id used in web.search_backend / web.extract_backend / web.backend
# config keys. Lowercase, no spaces; hyphens permitted.
return "my-backend"
@property
def display_name(self) -> str:
# Human label shown in `hermes tools`. Defaults to `name`.
return "My Backend"
def is_available(self) -> bool:
# Cheap check — env var present, optional dep importable, etc.
# MUST NOT make network calls (runs on every `hermes tools` paint).
return bool(os.getenv("MY_BACKEND_API_KEY", "").strip())
def supports_search(self) -> bool:
return True
def supports_extract(self) -> bool:
return False
def search(self, query: str, limit: int = 5) -> Dict[str, Any]:
import httpx
api_key = os.environ["MY_BACKEND_API_KEY"]
try:
resp = httpx.get(
"https://api.example.com/search",
params={"q": query, "count": max(1, min(int(limit), 20))},
headers={"Authorization": f"Bearer {api_key}"},
timeout=15,
)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPError as exc:
return {"success": False, "error": str(exc)}
# Response shape is fixed — see "Response shape" below.
return {
"success": True,
"data": {
"web": [
{
"title": item.get("title", ""),
"url": item.get("url", ""),
"description": item.get("snippet", ""),
"position": idx + 1,
}
for idx, item in enumerate(data.get("results", []))
],
},
}
```
```python
# plugins/web/my-backend/__init__.py
from plugins.web.my_backend.provider import MyBackendWebSearchProvider
def register(ctx) -> None:
"""Plugin entry point — called once at load time."""
ctx.register_web_search_provider(MyBackendWebSearchProvider())
```
## plugin.yaml
```yaml
name: web-my-backend
version: 1.0.0
description: "My Backend web search — Bearer-auth REST API"
author: Your Name
kind: backend
provides_web_providers:
- my-backend
requires_env:
- MY_BACKEND_API_KEY
```
| 键 | 用途 |
|---|---|
| `kind: backend` | 将插件路由至后端加载路径 |
| `provides_web_providers` | 该插件注册的提供商 `name` 列表——在 `register()` 运行之前,加载器即可通过此字段在 `hermes tools` 中公示插件 |
| `requires_env` | 在 `hermes plugins install` 期间进行交互式凭据提示(富格式说明参见[构建 Hermes 插件](/guides/build-a-hermes-plugin#gate-on-environment-variables) |
## ABC 参考
完整契约位于 `agent/web_search_provider.py`。可覆盖的方法如下:
| 成员 | 必须 | 默认值 | 用途 |
|---|---|---|---|
| `name` | ✅ | — | 在 `web.*_backend` 配置中使用的稳定 id |
| `display_name` | — | `name` | 在 `hermes tools` 中显示的标签 |
| `is_available()` | ✅ | — | 轻量可用性检查——环境变量、可选依赖等 |
| `supports_search()` | — | `True` | `web_search` 路由的能力标志 |
| `supports_extract()` | — | `False` | `web_extract` 路由的能力标志 |
| `search(query, limit)` | 条件必须 | 抛出异常 | 当 `supports_search()` 返回 `True` 时必须实现 |
| `extract(urls, **kwargs)` | 条件必须 | 抛出异常 | 当 `supports_extract()` 返回 `True` 时必须实现 |
提供商可以在单个类中声明多种能力——Firecrawl、Tavily、Exa 和 Parallel 均实现了搜索和提取两种能力。Brave Search 和 DDGS 仅支持搜索;SearXNG 也仅支持搜索,并有文档说明的"与提取提供商配对使用"工作流。
## 响应格式
工具包装器期望固定的响应信封(envelope),以避免在不同后端之间进行转换。
**搜索成功:**
```python
{
"success": True,
"data": {
"web": [
{"title": str, "url": str, "description": str, "position": int},
...
],
},
}
```
**提取成功:**
```python
{
"success": True,
"data": [
{
"url": str,
"title": str,
"content": str,
"raw_content": str,
"metadata": dict, # optional
"error": str, # optional, only on per-URL failure
},
...
],
}
```
**任意能力,失败时:**
```python
{"success": False, "error": "human-readable message"}
```
`search()``extract()` 均可定义为 `async def`——调度器通过 `inspect.iscoroutinefunction` 检测协程函数并相应地进行 await。对于小型后端,执行阻塞 I/O(HTTP、SDK 调用)的同步实现也完全可行;调度器会处理线程调度。
## 能力标志
Hermes 根据 `supports_*` 标志将调用路由至正确的提供商。一种常见的多提供商配置:
```yaml
# ~/.hermes/config.yaml
web:
search_backend: "brave-free" # 纯搜索,速度快,每月免费 2k 次
extract_backend: "firecrawl" # 提取 + 爬取,付费配额
```
`web.search_backend``web.extract_backend` 未设置时,均回退至 `web.backend`。若该项也未设置,Hermes 将根据环境变量的存在情况,选取第一个支持所请求能力的可用提供商。
如果你的提供商只支持一种能力,将其他标志保持默认值(`False`)即可,注册表会在对应工具调用时跳过它——当用户仅将 X 用于搜索而要求 agent 进行提取时,不会看到误导性的"提供商 X 失败"错误。
## Hermes 如何将其接入工具
`web_search``web_extract` 工具位于 `tools/web_tools.py`。调用时执行以下步骤:
1. 读取相关配置键(`web_search` 对应 `web.search_backend``web_extract` 对应 `web.extract_backend`
2. 向注册表查询具有该 `name` 的提供商
3. 检查 `is_available()` 及对应的 `supports_*()` 标志
4. 调度至 `search()` / `extract()` / `crawl()`,若方法为协程则进行 await
5. 将响应信封 JSON 序列化后返回给 LLM
错误以工具结果的形式呈现;LLM 决定如何解释。若没有提供商被注册(或所有可用提供商均未通过能力检查),工具将返回一条指向 `hermes tools` 的友好错误信息。
## 懒加载可选依赖
如果你的提供商封装了第三方 SDK(如 DDGS 封装了 `ddgs` 包),请勿在模块顶层 `import`。在 `is_available()``search()` 内部使用 `tools.lazy_deps.ensure(...)` ——Hermes 将在首次使用时安装该包,并受 `security.allow_lazy_installs` 控制。安全模型详见[构建 Hermes 插件 → 懒加载](/guides/build-a-hermes-plugin#lazy-install-optional-python-dependencies)。
## 参考实现
- **`plugins/web/brave_free/`** — 小型、需要 API key 的纯搜索 HTTP 提供商。适合作为起始模板。
- **`plugins/web/ddgs/`** — 无需 key、懒加载 SDK 的提供商。适用于封装 Python 包的后端。
- **`plugins/web/firecrawl/`** — 完整的多能力提供商(搜索 + 提取 + 爬取),支持多种格式模式。
- **`plugins/web/searxng/`** — 自托管、通过 URL 配置、无需认证的后端。
- **`plugins/web/xai/`** — 通过 Grok 服务端 `web_search` 工具实现的 LLM 驱动搜索。展示了如何复用现有的 OAuth/环境变量凭据(`tools/xai_http.py`)而无需新增环境变量,以及如何编写遵守无网络调用约定的轻量 `is_available()`
## 通过 pip 分发
```toml
# pyproject.toml
[project.entry-points."hermes_agent.plugins"]
my-backend-web = "my_backend_web_package"
```
`my_backend_web_package` 必须暴露顶层 `register` 函数。完整配置说明参见通用插件指南中的[通过 pip 分发](/guides/build-a-hermes-plugin#distribute-via-pip)。
## 相关页面
- [网页搜索](/user-guide/features/web-search) — 面向用户的功能文档及各后端配置说明
- [插件概览](/user-guide/features/plugins) — 所有插件类型一览
- [构建 Hermes 插件](/guides/build-a-hermes-plugin) — 通用工具/hook/斜杠命令指南