ByteNoteByteNote

字节笔记本

2026年5月16日

Hermes Agent 插件开发:从零构建自定义 Agent 插件

API中转
¥120

Hermes Agent 是一个功能强大的 AI Agent 框架,其插件系统是扩展核心能力的关键机制。本文将手把手带你从零构建一个完整的 Hermes 插件,涵盖 tool 定义、生命周期 hook、数据文件打包、skill 捆绑以及 pip 分发等全部环节。

确定你需要哪种扩展接口

Hermes 拥有多种可插拔接口——部分使用 Python register_* API,部分基于配置或目录挂载。请先参考下表定位:

如果你想添加…阅读指南
自定义 tool、hook、slash 命令、skill 或 CLI 子命令本文(通用 plugin 接口)
LLM / 推理后端(新 provider)Model Provider Plugins
Gateway 通道(Discord/Telegram/IRC/Teams 等)Adding Platform Adapters
记忆后端(Honcho/Mem0/Supermemory 等)Memory Provider Plugins
上下文压缩引擎Context Engine Plugins
图像生成后端Image Generation Provider Plugins
视频生成后端Video Generation Provider Plugins
TTS 后端(Piper、VoxCPM、Kokoro 等任何 CLI)TTS custom command providers —— 纯配置驱动,无需 Python
STT 后端(自定义 whisper / ASR CLI)Voice Message Transcription —— 设置 HERMES_LOCAL_STT_COMMAND 即可
通过 MCP 注册外部 tool(文件系统、GitHub、Linear 等)MCP —— 在 config.yaml 中声明 mcp_servers.<name>
Gateway 事件 hook(启动、会话事件、命令触发)Event Hooks —— 将 HOOK.yaml + handler.py 放入 ~/.hermes/hooks/<name>/
Shell hook(事件触发时运行 shell 命令)Shell Hooks —— 在 config.yamlhooks: 下声明
额外 skill 来源(自定义 GitHub 仓库、私有 skill 索引)Skills —— hermes skills tap add <repo>

目标插件概览

我们将构建一个 calculator 插件,包含两个 tool:

  • calculate —— 计算数学表达式(如 2**16sqrt(144)pi * 5**2
  • unit_convert —— 单位转换(如 100 F → 37.78 C5 km → 3.11 mi

此外还包含一个记录每次 tool 调用的 hook,以及一个捆绑的 skill 文件。

步骤一:创建插件目录

bash
mkdir -p ~/.hermes/plugins/calculator
cd ~/.hermes/plugins/calculator

步骤二:编写 manifest

创建 plugin.yaml

yaml
name: calculator
version: 1.0.0
description: Math calculator — evaluate expressions and convert units
provides_tools:
  - calculate
  - unit_convert
provides_hooks:
  - post_tool_call

这告诉 Hermes:"我叫 calculator,我提供 tool 和 hook。" provides_toolsprovides_hooks 字段列出了插件注册的内容。

可选字段:

yaml
author: Your Name
requires_env:          # 基于环境变量控制加载;安装时会提示填写
  - SOME_API_KEY       # 简单格式 —— 缺失时插件被禁用
  - name: OTHER_KEY    # 富格式 —— 安装时显示描述和链接
    description: "Key for the Other service"
    url: "https://other.com/keys"
    secret: true

步骤三:编写 Tool Schema

创建 schemas.py —— 这是 LLM 读取的内容,用于决定何时调用你的 tool:

python
"""Tool schemas — what the LLM sees."""

CALCULATE = {
    "name": "calculate",
    "description": (
        "Evaluate a mathematical expression and return the result. "
        "Supports arithmetic (+, -, *, /, **), functions (sqrt, sin, cos, "
        "log, abs, round, floor, ceil), and constants (pi, e). "
        "Use this for any math the user asks about."
    ),
    "parameters": {
        "type": "object",
        "properties": {
            "expression": {
                "type": "string",
                "description": "Math expression to evaluate (e.g., '2**10', 'sqrt(144)')",
            },
        },
        "required": ["expression"],
    },
}

UNIT_CONVERT = {
    "name": "unit_convert",
    "description": (
        "Convert a value between units. Supports length (m, km, mi, ft, in), "
        "weight (kg, lb, oz, g), temperature (C, F, K), data (B, KB, MB, GB, TB), "
        "and time (s, min, hr, day)."
    ),
    "parameters": {
        "type": "object",
        "properties": {
            "value": {
                "type": "number",
                "description": "The numeric value to convert",
            },
            "from_unit": {
                "type": "string",
                "description": "Source unit (e.g., 'km', 'lb', 'F', 'GB')",
            },
            "to_unit": {
                "type": "string",
                "description": "Target unit (e.g., 'mi', 'kg', 'C', 'MB')",
            },
        },
        "required": ["value", "from_unit", "to_unit"],
    },
}

Schema 的重要性: description 字段决定了 LLM 何时使用你的 tool,请务必具体描述其功能和适用场景。parameters 则定义了 LLM 传递的参数结构。

步骤四:编写 Tool Handler

创建 tools.py —— 这是 LLM 调用 tool 时实际执行的代码:

python
"""Tool handlers — the code that runs when the LLM calls each tool."""

import json
import math

# 安全的全局变量 —— 禁止文件/网络访问
_SAFE_MATH = {
    "abs": abs, "round": round, "min": min, "max": max,
    "pow": pow, "sqrt": math.sqrt, "sin": math.sin, "cos": math.cos,
    "tan": math.tan, "log": math.log, "log2": math.log2, "log10": math.log10,
    "floor": math.floor, "ceil": math.ceil,
    "pi": math.pi, "e": math.e,
    "factorial": math.factorial,
}


def calculate(args: dict, **kwargs) -> str:
    """安全地计算数学表达式。

    Handler 规则:
    1. 接收 args(dict)—— LLM 传递的参数
    2. 执行业务逻辑
    3. 返回 JSON 字符串 —— 始终如此,即使是错误情况
    4. 接受 **kwargs 以保持前向兼容
    """
    expression = args.get("expression", "").strip()
    if not expression:
        return json.dumps({"error": "No expression provided"})

    try:
        result = eval(expression, {"__builtins__": {}}, _SAFE_MATH)
        return json.dumps({"expression": expression, "result": result})
    except ZeroDivisionError:
        return json.dumps({"expression": expression, "error": "Division by zero"})
    except Exception as e:
        return json.dumps({"expression": expression, "error": f"Invalid: {e}"})


# 转换表 —— 值以基础单位表示
_LENGTH = {"m": 1, "km": 1000, "mi": 1609.34, "ft": 0.3048, "in": 0.0254, "cm": 0.01}
_WEIGHT = {"kg": 1, "g": 0.001, "lb": 0.453592, "oz": 0.0283495}
_DATA = {"B": 1, "KB": 1024, "MB": 1024**2, "GB": 1024**3, "TB": 1024**4}
_TIME = {"s": 1, "ms": 0.001, "min": 60, "hr": 3600, "day": 86400}


def _convert_temp(value, from_u, to_u):
    # 转换为摄氏度
    c = {"F": (value - 32) * 5/9, "K": value - 273.15}.get(from_u, value)
    # 转换为目标单位
    return {"F": c * 9/5 + 32, "K": c + 273.15}.get(to_u, c)


def unit_convert(args: dict, **kwargs) -> str:
    """单位转换。"""
    value = args.get("value")
    from_unit = args.get("from_unit", "").strip()
    to_unit = args.get("to_unit", "").strip()

    if value is None or not from_unit or not to_unit:
        return json.dumps({"error": "Need value, from_unit, and to_unit"})

    try:
        # 温度
        if from_unit.upper() in {"C","F","K"} and to_unit.upper() in {"C","F","K"}:
            result = _convert_temp(float(value), from_unit.upper(), to_unit.upper())
            return json.dumps({"input": f"{value} {from_unit}", "result": round(result, 4),
                             "output": f"{round(result, 4)} {to_unit}"})

        # 基于比例的转换
        for table in (_LENGTH, _WEIGHT, _DATA, _TIME):
            lc = {k.lower(): v for k, v in table.items()}
            if from_unit.lower() in lc and to_unit.lower() in lc:
                result = float(value) * lc[from_unit.lower()] / lc[to_unit.lower()]
                return json.dumps({"input": f"{value} {from_unit}",
                                 "result": round(result, 6),
                                 "output": f"{round(result, 6)} {to_unit}"})

        return json.dumps({"error": f"Cannot convert {from_unit} → {to_unit}"})
    except Exception as e:
        return json.dumps({"error": f"Conversion failed: {e}"})

Handler 核心规则:

  1. 签名: def my_handler(args: dict, **kwargs) -> str
  2. 返回值: 始终返回 JSON 字符串,包括成功和错误情况
  3. 永不抛异常: 捕获所有异常,返回错误 JSON
  4. 接受 **kwargs Hermes 未来可能传递额外的上下文信息

步骤五:编写注册逻辑

创建 __init__.py —— 将 schema 与 handler 关联起来:

python
"""Calculator plugin — registration."""

import logging

from . import schemas, tools

logger = logging.getLogger(__name__)

# 通过 hook 追踪 tool 使用情况
_call_log = []

def _on_post_tool_call(tool_name, args, result, task_id, **kwargs):
    """Hook:每次 tool 调用后执行(不仅限于本插件的 tool)。"""
    _call_log.append({"tool": tool_name, "session": task_id})
    if len(_call_log) > 100:
        _call_log.pop(0)
    logger.debug("Tool called: %s (session %s)", tool_name, task_id)


def register(ctx):
    """将 schema 关联到 handler 并注册 hook。"""
    ctx.register_tool(name="calculate",    toolset="calculator",
                      schema=schemas.CALCULATE,    handler=tools.calculate)
    ctx.register_tool(name="unit_convert", toolset="calculator",
                      schema=schemas.UNIT_CONVERT, handler=tools.unit_convert)

    # 此 hook 对所有 tool 调用生效,不仅限于本插件
    ctx.register_hook("post_tool_call", _on_post_tool_call)

register() 函数说明:

  • 启动时仅调用一次
  • ctx.register_tool() 将 tool 注册到 registry —— 模型立即可以使用
  • ctx.register_hook() 订阅生命周期事件
  • ctx.register_cli_command() 注册 CLI 子命令(如 hermes my-plugin <subcommand>
  • ctx.register_command() 注册会话内 slash 命令(如 CLI / gateway 聊天中的 /myplugin <args>
  • ctx.dispatch_tool(name, arguments) —— 调用任何其他 tool(内置或来自其他插件),自动携带父 agent 的上下文(审批、凭据、task_id)
  • 如果此函数崩溃,插件会被禁用但 Hermes 继续正常运行

dispatch_tool 示例 —— 一个调用 tool 的 slash 命令:

python
def handle_scan(ctx, argstr):
    """通过 /scan 调用 terminal tool。"""
    result = ctx.dispatch_tool("terminal", {"command": f"find . -name '{argstr}'"})
    return result  # 返回给调用者的聊天 UI

def register(ctx):
    ctx.register_command("scan", handle_scan, help="Find files matching a glob")

通过 dispatch_tool 调度的 tool 会走正常的审批、脱敏和预算流程 —— 这是真正的 tool 调用,不是绕过安全机制的后门。

步骤六:测试插件

启动 Hermes:

bash
hermes

启动后应看到 calculator: calculate, unit_convert 出现在 banner 的 tool 列表中。

试试以下提示:

text
What's 2 to the power of 16?
Convert 100 fahrenheit to celsius
What's the square root of 2 times pi?
How many gigabytes is 1.5 terabytes?

检查插件状态:

/plugins

输出:

text
Plugins (1):
  ✓ calculator v1.0.0 (2 tools, 1 hooks)

调试插件发现机制

如果插件未出现或加载失败,设置 HERMES_PLUGINS_DEBUG=1 获取详细发现日志:

bash
HERMES_PLUGINS_DEBUG=1 hermes plugins list

日志将包含:

  • 扫描了哪些目录、每个目录产出了多少 manifest
  • 每个 manifest 的 key、名称、类型、来源、磁盘路径
  • 跳过原因:disabled via confignot enabled in configexclusive pluginno plugin.yaml, depth cap reached
  • 加载时:正在导入的插件及 register(ctx) 注册内容的摘要
  • 解析失败:完整的异常堆栈
  • register() 失败:指向 __init__.py 中出错行的完整堆栈

同样可查看日志文件:

bash
hermes logs --level WARNING | grep -i plugin

插件未出现的常见原因:

  • 未在配置中启用 —— 插件是选择性启用的。运行 hermes plugins enable <name>
  • 目录结构错误 —— 必须是 ~/.hermes/plugins/<plugin-name>/plugin.yaml(扁平)或 ~/.hermes/plugins/<category>/<plugin-name>/plugin.yaml(一级分类嵌套,最多一层)
  • 缺少 __init__.py —— 插件目录需要同时包含 plugin.yaml 和带有 register(ctx) 函数的 __init__.py
  • kind 设置错误 —— Gateway 适配器需要 kind: platform;Memory provider 自动检测为 kind: exclusive

插件的最终目录结构

text
~/.hermes/plugins/calculator/
├── plugin.yaml      # "我是 calculator,我提供 tool 和 hook"
├── __init__.py      # 连接:schemas → handlers,注册 hook
├── schemas.py       # LLM 读取的内容(描述 + 参数规范)
└── tools.py         # 实际执行逻辑(calculate、unit_convert 函数)

四个文件,职责清晰:

  • Manifest 声明插件身份
  • Schemas 为 LLM 描述 tool
  • Handlers 实现实际逻辑
  • Registration 将一切串联起来

插件还能做什么?

打包数据文件

将文件放在插件目录中,在导入时读取:

python
# 在 tools.py 或 __init__.py 中
from pathlib import Path

_PLUGIN_DIR = Path(__file__).parent
_DATA_FILE = _PLUGIN_DIR / "data" / "languages.yaml"

with open(_DATA_FILE) as f:
    _DATA = yaml.safe_load(f)

捆绑 Skill

插件可以附带 skill 文件,agent 通过 skill_view("plugin:skill") 加载。在 __init__.py 中注册:

text
~/.hermes/plugins/my-plugin/
├── __init__.py
├── plugin.yaml
└── skills/
    ├── my-workflow/
    │   └── SKILL.md
    └── my-checklist/
        └── SKILL.md
python
from pathlib import Path

def register(ctx):
    skills_dir = Path(__file__).parent / "skills"
    for child in sorted(skills_dir.iterdir()):
        skill_md = child / "SKILL.md"
        if child.is_dir() and skill_md.exists():
            ctx.register_skill(child.name, skill_md)

Agent 可通过命名空间名称加载:

python
skill_view("my-plugin:my-workflow")   # → 插件版本
skill_view("my-workflow")              # → 内置版本(不受影响)

关键特性:

  • 插件 skill 是只读的 —— 不会进入 ~/.hermes/skills/,无法通过 skill_manage 编辑
  • 插件 skill 不会出现在系统提示的 <available_skills> 索引中 —— 需要显式加载
  • 命名空间机制防止与内置 skill 冲突
  • Agent 加载插件 skill 时,会前置显示同插件的其他 skill 列表

基于环境变量的门控

如果插件需要 API 密钥:

yaml
# plugin.yaml — 简单格式(向后兼容)
requires_env:
  - WEATHER_API_KEY

如果 WEATHER_API_KEY 未设置,插件会被禁用并显示清晰提示,不会崩溃或报错。

运行 hermes plugins install 时,用户会被交互式提示输入缺失的 requires_env 变量,值自动保存到 .env

使用富格式可获得更好的安装体验:

yaml
# plugin.yaml — 富格式
requires_env:
  - name: WEATHER_API_KEY
    description: "API key for OpenWeather"
    url: "https://openweathermap.org/api"
    secret: true
字段必填说明
name环境变量名称
description安装提示时显示给用户
url获取凭据的地址
secrettrue 时输入隐藏(类似密码)

两种格式可在同一列表中混用。已设置的变量会被静默跳过。

条件性 Tool 可用性

对于依赖可选库的 tool:

python
ctx.register_tool(
    name="my_tool",
    schema={...},
    handler=my_handler,
    check_fn=lambda: _has_optional_lib(),  # False = tool 对模型隐藏
)

覆盖内置 Tool

用自己的实现替换内置 tool(例如将默认 browser tool 替换为带界面的 Chrome CDP 后端,或用自定义企业索引替换 web_search),需传入 override=True

python
def register(ctx):
    ctx.register_tool(
        name="browser_navigate",             # 与内置 tool 同名
        toolset="plugin_my_browser",         # 你自己的 toolset 命名空间
        schema={...},
        handler=my_custom_navigate,
        override=True,                       # 显式选择覆盖
    )

不传 override=True 时,registry 会拒绝任何可能覆盖其他 toolset 已有 tool 的注册 —— 这防止了意外覆盖。覆盖操作会以 INFO 级别记录到日志中。插件在内置 tool 之后加载,因此注册顺序正确:你的 handler 会替换内置版本。

注册多个 Hook

python
def register(ctx):
    ctx.register_hook("pre_tool_call", before_any_tool)
    ctx.register_hook("post_tool_call", after_any_tool)
    ctx.register_hook("pre_llm_call", inject_memory)
    ctx.register_hook("on_session_start", on_new_session)
    ctx.register_hook("on_session_end", on_session_end)

Hook 参考

每个 hook 的完整文档见 Event Hooks reference。以下是概要:

Hook触发时机回调签名返回值
pre_tool_call任何 tool 执行前tool_name: str, args: dict, task_id: str忽略
post_tool_call任何 tool 返回后tool_name: str, args: dict, result: str, task_id: str, duration_ms: int忽略
pre_llm_call每轮一次,tool-calling 循环前session_id: str, user_message: str, conversation_history: list, is_first_turn: bool, model: str, platform: str上下文注入
post_llm_call每轮一次,tool-calling 循环后(仅成功轮次)session_id: str, user_message: str, assistant_response: str, conversation_history: list, model: str, platform: str忽略
on_session_start新会话创建(仅首轮)session_id: str, model: str, platform: str忽略
on_session_end每次 run_conversation 调用结束 + CLI 退出session_id: str, completed: bool, interrupted: bool, model: str, platform: str忽略
on_session_finalizeCLI/gateway 拆卸活跃会话`session_id: strNone, platform: str`
on_session_resetGateway 切换新会话 key(/new/resetsession_id: str, platform: str忽略

大多数 hook 是"发射后不管"的观察者 —— 返回值被忽略。例外是 pre_llm_call,它可以向对话注入上下文。

所有回调应接受 **kwargs 以保持前向兼容。如果 hook 回调崩溃,会被记录并跳过,其他 hook 和 agent 继续正常运行。

pre_llm_call 上下文注入

这是唯一返回值有意义的 hook。当 pre_llm_call 回调返回包含 "context" key 的 dict(或纯字符串)时,Hermes 会将该文本注入到当前轮次的用户消息中。这是记忆插件、RAG 集成、内容护栏等需要向模型提供额外上下文的插件的核心机制。

返回格式

python
# 带 context key 的 dict
return {"context": "Recalled memories:\n- User prefers dark mode\n- Last project: hermes-agent"}

# 纯字符串(等同于上面的 dict 形式)
return "Recalled memories:\n- User prefers dark mode"

# 返回 None 或不返回 → 不注入(仅观察者模式)
return None

注入的工作原理

注入的上下文被追加到用户消息中,而非系统提示。这是有意的设计决策:

  • 保护 prompt 缓存 —— 系统提示在各轮次间保持不变。Anthropic 和 OpenRouter 会缓存系统提示前缀,保持稳定可在多轮对话中节省 75% 以上的 input token。如果插件修改了系统提示,每一轮都会导致缓存未命中。
  • 临时性 —— 注入仅发生在 API 调用时。对话历史中的原始用户消息不会被修改,也不会持久化到会话数据库。
  • 系统提示是 Hermes 的领地 —— 它包含模型专属指引、tool 执行规则、人格指令和已缓存的 skill 内容。插件通过在用户输入旁贡献上下文来参与,而不是修改 agent 的核心指令。

示例:记忆召回插件

python
"""Memory plugin — 从向量存储中召回相关上下文。"""

import httpx

MEMORY_API = "https://your-memory-api.example.com"

def recall_context(session_id, user_message, is_first_turn, **kwargs):
    """在每次 LLM 轮次前调用。返回召回的记忆。"""
    try:
        resp = httpx.post(f"{MEMORY_API}/recall", json={
            "session_id": session_id,
            "query": user_message,
        }, timeout=3)
        memories = resp.json().get("results", [])
        if not memories:
            return None  # 无内容可注入

        text = "Recalled context from previous sessions:\n"
        text += "\n".join(f"- {m['text']}" for m in memories)
        return {"context": text}
    except Exception:
        return None  # 静默失败,不中断 agent

def register(ctx):
    ctx.register_hook("pre_llm_call", recall_context)

示例:内容护栏插件

python
"""Guardrails plugin — 强制执行内容策略。"""

POLICY = """You MUST follow these content policies for this session:
- Never generate code that accesses the filesystem outside the working directory
- Always warn before executing destructive operations
- Refuse requests involving personal data extraction"""

def inject_guardrails(**kwargs):
    """在每轮中注入策略文本。"""
    return {"context": POLICY}

def register(ctx):
    ctx.register_hook("pre_llm_call", inject_guardrails)

多个插件返回上下文

当多个插件从 pre_llm_call 返回上下文时,它们的输出以双换行连接并一起追加到用户消息中。顺序遵循插件发现顺序(按插件目录名字母排序)。

注册 CLI 命令

插件可以添加自己的 hermes <plugin> 子命令树:

python
def _my_command(args):
    """hermes my-plugin <subcommand> 的处理函数。"""
    sub = getattr(args, "my_command", None)
    if sub == "status":
        print("All good!")
    elif sub == "config":
        print("Current config: ...")
    else:
        print("Usage: hermes my-plugin <status|config>")

def _setup_argparse(subparser):
    """构建 hermes my-plugin 的 argparse 树。"""
    subs = subparser.add_subparsers(dest="my_command")
    subs.add_parser("status", help="Show plugin status")
    subs.add_parser("config", help="Show plugin config")
    subparser.set_defaults(func=_my_command)

def register(ctx):
    ctx.register_tool(...)
    ctx.register_cli_command(
        name="my-plugin",
        help="Manage my plugin",
        setup_fn=_setup_argparse,
        handler_fn=_my_command,
    )

注册后,用户可以运行 hermes my-plugin statushermes my-plugin config 等。

注册 Slash 命令

插件可以注册会话内 slash 命令 —— 用户在对话中输入的命令(如 /lcm status/ping),在 CLI 和 gateway(Telegram、Discord 等)中均可使用。

python
def _handle_status(raw_args: str) -> str:
    """/mystatus 的处理函数 —— 接收命令名之后的所有内容。"""
    if raw_args.strip() == "help":
        return "Usage: /mystatus [help|check]"
    return "Plugin status: all systems nominal"

def register(ctx):
    ctx.register_command(
        "mystatus",
        handler=_handle_status,
        description="Show plugin status",
    )

注册后,用户可以在任何会话中输入 /mystatus。命令会出现在自动补全、/help 输出和 Telegram 机器人菜单中。

签名: ctx.register_command(name: str, handler: Callable, description: str = "")

参数类型说明
namestr不含前导斜杠的命令名(如 "lcm""mystatus"
handler`Callable[[str], strNone]`
descriptionstr/help、自动补全和 Telegram 机器人菜单中显示

register_cli_command() 的区别:

register_command()register_cli_command()
调用方式会话中的 /name终端中的 hermes name
适用范围CLI 会话、Telegram、Discord 等仅终端
Handler 接收原始参数字符串argparse Namespace
用途诊断、状态查看、快速操作复杂子命令树、安装向导

冲突保护: 如果插件尝试注册与内置命令(helpmodelnew 等)冲突的名称,注册会被静默拒绝并记录警告。内置命令始终优先。

异步 Handler: Gateway 调度会自动检测并 await 异步 handler,因此同步和异步函数均可使用:

python
async def _handle_check(raw_args: str) -> str:
    result = await some_async_operation()
    return f"Check result: {result}"

def register(ctx):
    ctx.register_command("check", handler=_handle_check, description="Run async check")

从 Slash 命令调度 Tool

需要编排 tool 的 slash 命令 handler(通过 delegate_task 启动子 agent、调用 file_edit 等)应使用 ctx.dispatch_tool(),而非访问框架内部。父 agent 上下文(workspace 提示、spinner、模型继承)会自动装配。

python
def register(ctx):
    def _handle_deliver(raw_args: str):
        result = ctx.dispatch_tool(
            "delegate_task",
            {
                "goal": raw_args,
                "toolsets": ["terminal", "file", "web"],
            },
        )
        return result

    ctx.register_command(
        "deliver",
        handler=_handle_deliver,
        description="Delegate a goal to a subagent",
    )

签名: ctx.dispatch_tool(name: str, args: dict, *, parent_agent=None) -> str

参数类型说明
namestr在 tool registry 中注册的 tool 名称
argsdictTool 参数,与模型发送的格式相同
parent_agent`AgentNone`

运行时行为:

  • CLI 模式: parent_agent 从活跃的 CLI agent 解析,workspace 提示、spinner 和模型选择自动继承
  • Gateway 模式: 没有 CLI agent,tool 优雅降级 —— workspace 从 TERMINAL_CWD 读取,不显示 spinner
  • 显式覆盖: 如果调用者显式传入 parent_agent=,该值会被尊重

专用插件类型

Hermes 除了通用插件接口外,还有五种专用插件类型。每种以 plugins/<category>/<name>/(内置)或 ~/.hermes/plugins/<category>/<name>/(用户)目录形式提供。不同类别的契约不同 —— 选择你需要的,然后阅读完整指南。

Model Provider 插件 —— 添加 LLM 后端

将 profile 放入 plugins/model-providers/<name>/

python
# plugins/model-providers/acme/__init__.py
from providers import register_provider
from providers.base import ProviderProfile

register_provider(ProviderProfile(
    name="acme",
    aliases=("acme-inference",),
    display_name="Acme Inference",
    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"),
))
yaml
# plugins/model-providers/acme/plugin.yaml
name: acme-provider
kind: model-provider
version: 1.0.0
description: Acme Inference — OpenAI-compatible direct API

首次调用 get_provider_profile()list_providers() 时延迟发现。用户插件按名称覆盖内置插件。

Platform 插件 —— 添加 Gateway 通道

将适配器放入 plugins/platforms/<name>/

python
# plugins/platforms/myplatform/adapter.py
from gateway.platforms.base import BasePlatformAdapter

class MyPlatformAdapter(BasePlatformAdapter):
    async def connect(self): ...
    async def send(self, chat_id, text): ...
    async def disconnect(self): ...

def check_requirements():
    import os
    return bool(os.environ.get("MYPLATFORM_TOKEN"))

def _env_enablement():
    import os
    tok = os.getenv("MYPLATFORM_TOKEN", "").strip()
    if not tok:
        return None
    return {"token": tok}

def register(ctx):
    ctx.register_platform(
        name="myplatform",
        label="MyPlatform",
        adapter_factory=lambda cfg: MyPlatformAdapter(cfg),
        check_fn=check_requirements,
        required_env=["MYPLATFORM_TOKEN"],
        env_enablement_fn=_env_enablement,
        cron_deliver_env_var="MYPLATFORM_HOME_CHANNEL",
        emoji="💬",
        platform_hint="You are chatting via MyPlatform. Keep responses concise.",
    )

Memory Provider 插件 —— 添加跨会话知识后端

MemoryProvider 的实现放入 plugins/memory/<name>/

python
# plugins/memory/my-memory/__init__.py
from agent.memory_provider import MemoryProvider

class MyMemoryProvider(MemoryProvider):
    @property
    def name(self) -> str:
        return "my-memory"

    def is_available(self) -> bool:
        import os
        return bool(os.environ.get("MY_MEMORY_API_KEY"))

    def initialize(self, session_id: str, **kwargs) -> None:
        self._session_id = session_id

    def sync_turn(self, user_message, assistant_response, **kwargs) -> None:
        ...

    def prefetch(self, query: str, **kwargs) -> str | None:
        ...

def register(ctx):
    ctx.register_memory_provider(MyMemoryProvider())

Memory provider 是单选的 —— 同一时间只有一个活跃,通过 config.yaml 中的 memory.provider 选择。

Context Engine 插件 —— 替换上下文压缩器

python
# plugins/context_engine/my-engine/__init__.py
from agent.context_engine import ContextEngine

class MyContextEngine(ContextEngine):
    @property
    def name(self) -> str:
        return "my-engine"

    def should_compress(self, messages, model) -> bool: ...
    def compress(self, messages, model) -> list[dict]: ...

def register(ctx):
    ctx.register_context_engine(MyContextEngine())

Context engine 是单选的 —— 通过 config.yaml 中的 context.engine 选择。

图像生成后端

将 provider 放入 plugins/image_gen/<name>/

python
# plugins/image_gen/my-imggen/__init__.py
from agent.image_gen_provider import ImageGenProvider

class MyImageGenProvider(ImageGenProvider):
    @property
    def name(self) -> str:
        return "my-imggen"

    def is_available(self) -> bool: ...
    def generate(self, prompt: str, **kwargs) -> str: ...   # 返回图片路径

def register(ctx):
    ctx.register_image_gen_provider(MyImageGenProvider())
yaml
# plugins/image_gen/my-imggen/plugin.yaml
name: my-imggen
kind: backend
version: 1.0.0
description: Custom image generation backend

非 Python 扩展接口

Hermes 还接受非 Python 插件的扩展方式。

MCP 服务器 —— 注册外部 Tool

Model Context Protocol (MCP) 服务器无需 Python 插件即可将自己的 tool 注册到 Hermes 中。在 ~/.hermes/config.yaml 中声明:

yaml
mcp_servers:
  filesystem:
    command: "npx"
    args: ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"]
    timeout: 120

  linear:
    url: "https://mcp.linear.app/sse"
    auth:
      type: "oauth"

Hermes 在启动时连接每个服务器,列出其 tool,并与内置 tool 一起注册。LLM 像使用其他 tool 一样使用它们。

Gateway 事件 Hook —— 响应生命周期事件

将 manifest 和 handler 放入 ~/.hermes/hooks/<name>/

yaml
# ~/.hermes/hooks/long-task-alert/HOOK.yaml
name: long-task-alert
description: Send a push notification when a long task finishes
events:
  - agent:end
python
# ~/.hermes/hooks/long-task-alert/handler.py
async def handle(event_type: str, context: dict) -> None:
    if context.get("duration_seconds", 0) > 120:
        # 发送通知…
        pass

事件包括 gateway:startupsession:startsession:endsession:resetagent:startagent:stepagent:end 和通配符 command:*。hook 中的错误会被捕获并记录 —— 永远不会阻塞主流程。

Shell Hook —— 在 Tool 调用时运行 Shell 命令

如果只想在 tool 触发时运行脚本(通知、审计日志、桌面提醒、自动格式化),在 config.yaml 中使用 shell hook —— 无需 Python:

yaml
hooks:
  - event: post_tool_call
    command: "notify-send 'Tool ran: {tool_name}'"
    when:
      tools: [terminal, patch, write_file]

Skill 来源 —— 添加自定义 Skill 仓库

如果维护一个 GitHub 仓库的 skill(或想从社区索引拉取),将其添加为 tap

bash
hermes skills tap add myorg/skills-repo
hermes skills search my-workflow --source myorg/skills-repo
hermes skills install myorg/skills-repo/my-workflow

发布自己的 tap 只需要一个包含 skills/<skill-name>/SKILL.md 目录的 GitHub 仓库 —— 无需服务器或注册。

TTS / STT 命令模板

任何读写音频或文本的 CLI 都可以通过 config.yaml 接入 —— 无需 Python 代码:

yaml
tts:
  provider: voxcpm
  providers:
    voxcpm:
      type: command
      command: "voxcpm --ref ~/voice.wav --text-file {input_path} --out {output_path}"
      output_format: mp3
      voice_compatible: true

对于 STT,将 HERMES_LOCAL_STT_COMMAND 指向 shell 模板。支持的占位符:{input_path}{output_path}{format}{voice}{model}{speed}(TTS);{input_path}{output_dir}{language}{model}(STT)。

通过 pip 分发

要通过 pip 公开分享插件,在 Python 包中添加 entry point:

toml
# pyproject.toml
[project.entry-points."hermes_agent.plugins"]
my-plugin = "my_plugin_package"
bash
pip install hermes-plugin-calculator
# 下次启动 hermes 时自动发现插件

为 NixOS 分发

NixOS 用户可以通过 pyproject.toml 和 entry point 声明式安装插件:

Entry-point 插件(推荐分发方式):

nix
# User's configuration.nix
services.hermes-agent.extraPythonPackages = [
  (pkgs.python312Packages.buildPythonPackage {
    pname = "my-plugin";
    version = "1.0.0";
    src = pkgs.fetchFromGitHub {
      owner = "you";
      repo = "hermes-my-plugin";
      rev = "v1.0.0";
      hash = "sha256-...";  # nix-prefetch-url --unpack
    };
    format = "pyproject";
    build-system = [ pkgs.python312Packages.setuptools ];
  })
];

目录插件(无需 pyproject.toml):

nix
services.hermes-agent.extraPlugins = [
  (pkgs.fetchFromGitHub {
    owner = "you";
    repo = "hermes-my-plugin";
    rev = "v1.0.0";
    hash = "sha256-...";
  })
];

常见错误

Handler 未返回 JSON 字符串:

python
# 错误 —— 返回了 dict
def handler(args, **kwargs):
    return {"result": 42}

# 正确 —— 返回 JSON 字符串
def handler(args, **kwargs):
    return json.dumps({"result": 42})

Handler 签名缺少 **kwargs

python
# 错误 —— Hermes 传递额外上下文时会报错
def handler(args):
    ...

# 正确
def handler(args, **kwargs):
    ...

Handler 抛出异常:

python
# 错误 —— 异常传播,tool 调用失败
def handler(args, **kwargs):
    result = 1 / int(args["value"])  # ZeroDivisionError!
    return json.dumps({"result": result})

# 正确 —— 捕获异常并返回错误 JSON
def handler(args, **kwargs):
    try:
        result = 1 / int(args.get("value", 0))
        return json.dumps({"result": result})
    except Exception as e:
        return json.dumps({"error": str(e)})

Schema 描述过于模糊:

python
# 差 —— 模型不知道何时使用
"description": "Does stuff"

# 好 —— 模型清楚知道何时以及如何使用
"description": "Evaluate a mathematical expression. Use for arithmetic, trig, logarithms. Supports: +, -, *, /, **, sqrt, sin, cos, log, pi, e."
分享: