本指南帶你從零開始打造一個完整的 Hermes 外掛。完成後你將擁有一個可用的外掛,包含多個工具、生命週期鉤子、附帶的資料檔案,以及一個內建技能——支援外掛系統的所有功能。
INFO — 不確定你需要哪個指南?
Hermes 有多種不同的可插拔介面——有些使用 Python
register_*API,有些是設定驅動或目錄放入式的。先用這個對照表:
如果你想加入… 閱讀 自訂工具、鉤子、斜線指令、技能或 CLI 子指令 本指南(通用外掛介面) LLM / 推理後端(新的供應商) Model Provider Plugins Gateway 頻道(Discord/Telegram/IRC/Teams 等) Adding Platform Adapters 記憶後端(Honcho/Mem0/Supermemory 等) Memory Provider Plugins Context 壓縮引擎 Context Engine Plugins 圖片生成後端 Image Generation Provider Plugins 影片生成後端 Video Generation Provider Plugins TTS 後端(任何 CLI——Piper、VoxCPM、Kokoro、聲音複製等) TTS custom command providers—設定驅動,不需要 Python STT 後端(自訂 whisper / ASR CLI) Voice Message Transcription—將 HERMES_LOCAL_STT_COMMAND設為 shell 範本透過 MCP 的外部工具(檔案系統、GitHub、Linear、任何 MCP 伺服器) MCP—在 config.yaml中宣告mcp_servers.<name>Gateway 事件鉤子(在啟動、Session 事件、指令時觸發) Event Hooks—將 HOOK.yaml+handler.py放入~/.hermes/hooks/<name>/Shell 鉤子(在事件時執行 shell 指令) Shell Hooks—在 config.yaml的hooks:下宣告額外的技能來源(自訂 GitHub 儲存庫、私有技能索引) Skills— hermes skills tap add <repo>·Publishing a tap一級核心推理供應商(非外掛) Adding Providers 完整的可插拔介面對照表提供所有擴充介面的整合視圖,包括設定驅動(TTS、STT、MCP、shell 鉤子)和目錄放入式(gateway 鉤子)兩種風格。
你要打造什麼
一個帶有兩個工具的計算機外掛:
calculate— 評估數學運算式(2**16、sqrt(144)、pi * 5**2)unit_convert— 在單位之間轉換(100 F → 37.78 C、5 km → 3.11 mi)
加上一個記錄每次工具呼叫的鉤子,以及一個內建技能檔案。
步驟 1:建立外掛目錄
mkdir -p ~/.hermes/plugins/calculator
cd ~/.hermes/plugins/calculator
步驟 2:撰寫 manifest
建立 plugin.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 的外掛,我提供工具和鉤子。」provides_tools 和 provides_hooks 是外掛所註冊項目的清單。
你可以加入的選擇性欄位:
author: Your Name
requires_env: # 以環境變數作為載入門檻;安裝時會提示
- SOME_API_KEY # 簡單格式——缺少時外掛停用
- name: OTHER_KEY # 豐富格式——安裝時顯示描述/網址
description: "Key for the Other service"
url: "https://other.com/keys"
secret: true
步驟 3:撰寫工具 Schema
建立 schemas.py——這是 LLM 用來決定何時呼叫你的工具的內容:
"""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 決定何時使用你的工具的依據。要具體描述它的功能和使用時機。parameters 定義了 LLM 傳遞的參數。
步驟 4:撰寫工具處理器
建立 tools.py——這是 LLM 呼叫你的工具時實際執行的程式碼:
"""Tool handlers — the code that runs when the LLM calls each tool."""
import json
import math
# Safe globals for expression evaluation — no file/network access
_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:
"""Evaluate a math expression safely.
Rules for handlers:
1. Receive args (dict) — the parameters the LLM passed
2. Do the work
3. Return a JSON string — ALWAYS, even on error
4. Accept **kwargs for forward compatibility
"""
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}"})
# Conversion tables — values are in base units
_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):
# Normalize to Celsius
c = {"F": (value - 32) * 5/9, "K": value - 273.15}.get(from_u, value)
# Convert to target
return {"F": c * 9/5 + 32, "K": c + 273.15}.get(to_u, c)
def unit_convert(args: dict, **kwargs) -> str:
"""Convert between units."""
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:
# Temperature
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}"})
# Ratio-based conversions
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}"})
處理器的關鍵規則:
- 簽名:
def my_handler(args: dict, **kwargs) -> str - **回傳值:**永遠是 JSON 字串。成功和錯誤都是。
- **永遠不要拋出例外:**捕捉所有例外,回傳錯誤 JSON。
- **接受
**kwargs:**Hermes 未來可能會傳遞額外的 context。
步驟 5:撰寫註冊程式
建立 __init__.py——這將 Schema 與處理器串接起來:
"""Calculator plugin — registration."""
import logging
from . import schemas, tools
logger = logging.getLogger(__name__)
# Track tool usage via hooks
_call_log = []
def _on_post_tool_call(tool_name, args, result, task_id, **kwargs):
"""Hook: runs after every tool call (not just ours)."""
_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):
"""Wire schemas to handlers and register hooks."""
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)
# This hook fires for ALL tool calls, not just ours
ctx.register_hook("post_tool_call", _on_post_tool_call)
register() 的功能:
- 在啟動時只呼叫一次
ctx.register_tool()將你的工具加入註冊表——模型會立即看到它ctx.register_hook()訂閱生命週期事件ctx.register_cli_command()註冊一個 CLI 子指令(例如hermes my-plugin <subcommand>)ctx.register_command()註冊一個對話中的斜線指令(例如 CLI / gateway 對話中的/myplugin <args>)——見下方註冊斜線指令ctx.dispatch_tool(name, arguments)— 呼叫任何其他工具(內建或來自其他外掛),自動串接父代理的 context(審批、憑證、task_id)。適用於需要呼叫terminal、read_file或任何其他工具的斜線指令處理器,就像模型直接呼叫一樣。- 如果這個函式崩潰,外掛會被停用但 Hermes 繼續正常運作
dispatch_tool 範例——一個執行工具的斜線指令:
def handle_scan(ctx, raw_args: str):
"""Implement /scan by invoking the terminal tool through the registry."""
result = ctx.dispatch_tool("terminal", {"command": f"find . -name '{raw_args}'"})
return result # returned to the caller's chat UI
def register(ctx):
# Handlers receive a single raw_args string; close over ctx via a lambda.
ctx.register_command(
"scan",
lambda raw: handle_scan(ctx, raw),
description="Find files matching a glob",
)
被派遣的工具會經過正常的審批、遮蔽和預算管線——這是一個真正的工具呼叫,不是繞過它們的捷徑。
步驟 6:測試
啟動 Hermes:
hermes
你應該會在啟動橫幅的工具列表中看到 calculator: calculate, unit_convert。
試試這些提示:
2 的 16 次方是多少?
將 100 華氏度轉換為攝氏度
2 乘以 pi 的平方根是多少?
1.5 terabytes 等於多少 gigabytes?
檢查外掛狀態:
/plugins
輸出:
Plugins (1):
✓ calculator v1.0.0 (2 tools, 1 hooks)
除錯外掛發現
如果你的外掛沒有顯示——或者顯示了但沒有載入——設定 HERMES_PLUGINS_DEBUG=1 以在 stderr 上取得詳細的發現日誌:
HERMES_PLUGINS_DEBUG=1 hermes plugins list
你會看到每個外掛來源(內建、使用者、專案、entry-points)的:
- 掃描了哪些目錄以及每個目錄產生了多少個 manifest
- 每個 manifest:解析的 key、名稱、類型、來源、磁碟路徑
- 跳過原因:
disabled via config、not enabled in config、exclusive plugin、no plugin.yaml, depth cap reached - 載入時:正在匯入的外掛,加上
register(ctx)註冊內容的一行摘要(工具、鉤子、斜線指令、CLI 指令) - 解析失敗時:例外的完整堆疊追蹤(YAML 掃描錯誤等)
register()失敗時:指向__init__.py中拋出例外那一行的完整堆疊追蹤
相同的日誌會在 WARNING 層級(僅失敗時)和 DEBUG 層級(完整資訊,需設定環境變數時)寫入 ~/.hermes/logs/agent.log。所以如果你無法使用環境變數執行(例如在 gateway 內),可以改用 tail 查看日誌檔:
hermes logs --level WARNING | grep -i plugin
外掛不出現的常見原因:
- 未在設定中啟用 — 外掛是選擇性啟用的。執行
hermes plugins enable <name>(名稱來自plugins list輸出,巢狀結構的外掛可能顯示為<category>/<plugin>)。 - 目錄結構錯誤 — 必須是
~/.hermes/plugins/<plugin-name>/plugin.yaml(扁平式)或~/.hermes/plugins/<category>/<plugin-name>/plugin.yaml(最多一層分類巢狀)。更深的結構會被忽略。 - 缺少
__init__.py— 外掛目錄需要同時有plugin.yaml和包含register(ctx)函式的__init__.py。 - 錯誤的
kind— Gateway 適配器需要在 manifest 中設定kind: platform。記憶供應商會被自動偵測為kind: exclusive,透過memory.provider設定路由而非plugins.enabled。
你的外掛最終結構
~/.hermes/plugins/calculator/
├── plugin.yaml # 「我是 calculator,我提供工具和鉤子」
├── __init__.py # 串接:schemas → handlers,註冊鉤子
├── schemas.py # LLM 讀取的內容(描述 + 參數規格)
└── tools.py # 實際執行的程式碼(calculate、unit_convert 函式)
四個檔案,職責分明:
- Manifest 宣告外掛是什麼
- Schema 為 LLM 描述工具
- 處理器 實作實際邏輯
- 註冊 連接一切
外掛還能做什麼?
附帶資料檔案
將任何檔案放在你的外掛目錄中,在匯入時讀取:
# In tools.py or __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_view("plugin:skill") 載入。在你的 __init__.py 中註冊:
~/.hermes/plugins/my-plugin/
├── __init__.py
├── plugin.yaml
└── skills/
├── my-workflow/
│ └── SKILL.md
└── my-checklist/
└── SKILL.md
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)
代理現在可以用命名空間名稱載入你的技能:
skill_view("my-plugin:my-workflow") # → 外掛的版本
skill_view("my-workflow") # → 內建版本(不受影響)
關鍵特性:
- 外掛技能是唯讀的——它們不會進入
~/.hermes/skills/,無法透過skill_manage編輯。 - 外掛技能不會列在系統提示的
<available_skills>索引中——它們是選擇性顯式載入的。 - 裸技能名稱不受影響——命名空間防止了與內建技能的衝突。
- 當代理載入外掛技能時,會在前面加上一個套件上下文橫幅,列出來自同一外掛的兄弟技能。
TIP — 舊版模式
舊的
shutil.copy2模式(將技能複製到~/.hermes/skills/)仍然有效,但有與內建技能名稱衝突的風險。新外掛請優先使用ctx.register_skill()。
以環境變數作為門檻
如果你的外掛需要 API 金鑰:
# plugin.yaml — 簡單格式(向後相容)
requires_env:
- WEATHER_API_KEY
如果 WEATHER_API_KEY 未設定,外掛會被停用並顯示清楚的訊息。不會崩潰,不會在代理中產生錯誤——只會顯示「Plugin weather disabled (missing: WEATHER_API_KEY)」。
當使用者執行 hermes plugins install 時,會互動式提示輸入任何缺少的 requires_env 變數。值會自動儲存到 .env。
為了更好的安裝體驗,使用帶有描述和註冊網址的豐富格式:
# plugin.yaml — 豐富格式
requires_env:
- name: WEATHER_API_KEY
description: "API key for OpenWeather"
url: "https://openweathermap.org/api"
secret: true
| 欄位 | 必要 | 說明 |
|---|---|---|
name | 是 | 環境變數名稱 |
description | 否 | 安裝提示時顯示給使用者 |
url | 否 | 取得憑證的地方 |
secret | 否 | 如果為 true,輸入會被隱藏(如密碼欄位) |
兩種格式可以在同一個清單中混用。已設定的變數會被靜默跳過。
延遲安裝選擇性 Python 依賴
如果你的外掛包裝了一個不是每個使用者都會安裝的 SDK(供應商 SDK、大型 ML 函式庫、平台特定套件),不要在模組頂部 import 它。在工具處理器內部使用 tools.lazy_deps.ensure(...) 輔助函式——Hermes 會在首次使用時安裝該套件,由使用者的 security.allow_lazy_installs 設定門檻控制。
# tools.py
from tools.lazy_deps import ensure, FeatureUnavailable
def my_tool_handler(args, **kwargs):
try:
ensure("my-plugin.my-backend") # key must be in LAZY_DEPS
except FeatureUnavailable as exc:
return {"error": str(exc)}
import my_backend_sdk # safe now
...
tools/lazy_deps.py 中安全模型的兩條規則:
| 規則 | 原因 |
|---|---|
你的功能 key 必須出現在 tree 內的 LAZY_DEPS 允許清單中 | 防止惡意設定誘導 Hermes 安裝任意套件——只有 Hermes 本身附帶的規格才有資格 |
| 規格僅限 PyPI 名稱 | 不支援 --index-url、git+https:// 或 file: 路徑。在允許清單條目內使用 PEP 440("my-sdk>=1.2,<2")固定版本 |
對於透過 pip 分發的第三方外掛,將選擇性依賴宣告為你自己 pyproject.toml 中的 [project.optional-dependencies] extras,並告知使用者執行 pip install your-plugin[backend]——該路徑不經過 lazy_deps。延遲安裝機制最適用於內建外掛,因為在每次安裝時附帶硬依賴會膨脹 Hermes 的基礎佔用空間。
當 security.allow_lazy_installs: false 在全域設定時,ensure() 會立即拋出 FeatureUnavailable 並附上修正提示——你的外掛應該捕捉它並優雅降級(回傳錯誤結果,而不是崩潰工具迴圈)。
執行緒安全的延遲單例
外掛通常會快取一個高成本物件——SDK 客戶端、HTTP session、連線池——在首次使用的模組層級變數中:
_client = None
def get_client():
global _client
if _client is not None:
return _client
_client = ExpensiveClient(...) # ← TOCTOU 競爭條件
return _client
這是一個隱藏的陷阱。Hermes 在一個程序中運行多個執行緒(委派的工具呼叫、背景工作者、自我改善分叉),所以兩個執行緒可能在 _client 設定之前都呼叫 get_client(),兩者都通過 is not None 檢查,兩者都執行高成本的建立,第二次寫入覆蓋第一次——洩漏失敗者開啟的任何資源(連線、檔案控制代碼、背景執行緒)。
不要手動實作鎖。使用 plugins/plugin_utils.py 中的輔助函式:
from plugins.plugin_utils import lazy_singleton, SingletonSlot
# Zero-arg accessor → decorate it:
@lazy_singleton
def get_client():
return ExpensiveClient(load_config()) # runs exactly once
client = get_client() # safe across threads
get_client.reset() # drop the instance (tests / teardown)
# Accessor that takes a build argument → use a slot:
_slot: SingletonSlot = SingletonSlot()
def get_client(config=None):
return _slot.get(lambda: ExpensiveClient(resolve(config)))
def reset_client():
_slot.reset()
兩者都使用雙重檢查鎖定序列化並行的首次呼叫,並最多執行一次工廠函式。如果工廠拋出例外,不會快取任何內容,下次呼叫會重試。Honcho 記憶外掛(plugins/memory/honcho/client.py)是參考消費者。
經驗法則:每當你寫
global _something後面跟著is None檢查和建立時,改用這些輔助函式。
條件式工具可用性
對於依賴選擇性函式庫的工具:
ctx.register_tool(
name="my_tool",
schema={...},
handler=my_handler,
check_fn=lambda: _has_optional_lib(), # False = tool hidden from model
)
覆蓋內建工具
要用自己的實作替換內建工具(例如將預設的瀏覽器工具替換為 headed-Chrome CDP 後端,或用自訂的企業索引替換 web_search),傳入 override=True:
def register(ctx):
ctx.register_tool(
name="browser_navigate", # same name as the built-in
toolset="plugin_my_browser", # your own toolset namespace
schema={...},
handler=my_custom_navigate,
override=True, # explicit opt-in
)
沒有 override=True 的情況下,註冊表會拒絕任何會遮蔽來自不同工具集的現有工具的註冊——這防止了意外覆蓋。覆蓋會在 INFO 層級記錄,可在 ~/.hermes/logs/agent.log 中稽核。外掛在內建工具之後載入,所以註冊順序是正確的:你的處理器替換了內建的處理器。
註冊多個鉤子
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)
鉤子參考
每個鉤子都在 Event Hooks 參考 中有完整文件——回調簽名、參數表、精確的觸發時機和範例。以下是摘要:
| 鉤子 | 觸發時機 | 回調簽名 | 回傳值 |
|---|---|---|---|
pre_tool_call | 任何工具執行前 | tool_name: str, args: dict, task_id: str | 忽略 |
post_tool_call | 任何工具回傳後 | tool_name: str, args: dict, result: str, task_id: str, duration_ms: int | 忽略 |
pre_llm_call | 每回合一次,在工具呼叫迴圈之前 | session_id: str, user_message: str, conversation_history: list, is_first_turn: bool, model: str, platform: str | context 注入 |
post_llm_call | 每回合一次,在工具呼叫迴圈之後(僅成功回合) | session_id: str, user_message: str, assistant_response: str, conversation_history: list, model: str, platform: str | 忽略 |
on_session_start | 新 Session 建立(僅首次回合) | 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_finalize | CLI/gateway 拆除活躍 Session | session_id: str | None, platform: str | 忽略 |
on_session_reset | Gateway 切換新的 Session key(/new、/reset) | session_id: str, platform: str | 忽略 |
大多數鉤子是即發即忘的觀察者——它們的回傳值被忽略。例外是 pre_llm_call,它可以注入 context 到對話中。
所有回調都應接受 **kwargs 以確保向前相容。如果鉤子回調崩潰,會被記錄並跳過。其他鉤子和代理繼續正常運作。
pre_llm_call context 注入
這是唯一回傳值重要的鉤子。當 pre_llm_call 回調回傳一個帶有 "context" key 的 dict(或一個純字串)時,Hermes 會將該文字注入到目前回合的使用者訊息中。這是記憶外掛、RAG 整合、護欄和任何需要為模型提供額外 context 的外掛的機制。
回傳格式
# Dict with context key
return {"context": "Recalled memories:\n- User prefers dark mode\n- Last project: hermes-agent"}
# Plain string (equivalent to the dict form above)
return "Recalled memories:\n- User prefers dark mode"
# Return None or don't return → no injection (observer-only)
return None
任何非 None、非空且帶有 "context" key 的回傳(或純非空字串)都會被收集並附加到目前回合的使用者訊息。
注入運作方式
注入的 context 附加到使用者訊息,而非系統提示。這是刻意的設計選擇:
- Prompt 快取保護 — 系統提示在各回合之間保持一致。Anthropic 和 OpenRouter 會快取系統提示前綴,所以保持穩定可在多回合對話中節省 75% 以上的輸入 token。如果外掛修改了系統提示,每個回合都會是快取未命中。
- 暫時性 — 注入只在 API 呼叫時發生。對話歷史中的原始使用者訊息永遠不會被修改,也不會持久化到 Session 資料庫。
- 系統提示是 Hermes 的領域 — 它包含模型特定的指引、工具強制規則、人格指令和快取的技能內容。外掛在使用者輸入旁邊貢獻 context,而不是修改代理的核心指令。
範例:記憶回憶外掛
"""Memory plugin — recalls relevant context from a vector store."""
import httpx
MEMORY_API = "https://your-memory-api.example.com"
def recall_context(session_id, user_message, is_first_turn, **kwargs):
"""Called before each LLM turn. Returns recalled memories."""
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 # nothing to inject
text = "Recalled context from previous sessions:\n"
text += "\n".join(f"- {m['text']}" for m in memories)
return {"context": text}
except Exception:
return None # fail silently, don't break the agent
def register(ctx):
ctx.register_hook("pre_llm_call", recall_context)
範例:護欄外掛
"""Guardrails plugin — enforces content policies."""
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):
"""Injects policy text into every turn."""
return {"context": POLICY}
def register(ctx):
ctx.register_hook("pre_llm_call", inject_guardrails)
範例:純觀察者鉤子(不注入)
"""Analytics plugin — tracks turn metadata without injecting context."""
import logging
logger = logging.getLogger(__name__)
def log_turn(session_id, user_message, model, is_first_turn, **kwargs):
"""Fires before each LLM call. Returns None — no context injected."""
logger.info("Turn: session=%s model=%s first=%s msg_len=%d",
session_id, model, is_first_turn, len(user_message or ""))
# No return → no injection
def register(ctx):
ctx.register_hook("pre_llm_call", log_turn)
多個外掛回傳 context
當多個外掛從 pre_llm_call 回傳 context 時,它們的輸出會以雙換行符連接並一起附加到使用者訊息。順序依外掛發現順序(按外掛目錄名稱字母順序)。
註冊 CLI 指令
外掛可以加入自己的 hermes <plugin> 子指令樹:
def _my_command(args):
"""Handler for 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):
"""Build the argparse tree for hermes my-plugin."""
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 status、hermes my-plugin config 等。
記憶供應商外掛使用基於慣例的方法:在你的外掛的 cli.py 檔案中加入 register_cli(subparser) 函式。記憶外掛發現系統會自動找到它——不需要 ctx.register_cli_command() 呼叫。詳見 Memory Provider Plugin 指南。
**啟用供應商門檻:**記憶外掛 CLI 指令只在它們的供應商是設定中的啟用 memory.provider 時才會出現。如果使用者尚未設定你的供應商,你的 CLI 指令不會弄亂幫助輸出。
註冊斜線指令
外掛可以註冊對話中的斜線指令——使用者在對話中輸入的指令(如 /lcm status 或 /ping)。這在 CLI 和 gateway(Telegram、Discord 等)中都有效。
def _handle_status(raw_args: str) -> str:
"""Handler for /mystatus — called with everything after the command name."""
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",
)
註冊後,使用者可以在任何 Session 中輸入 /mystatus。指令會出現在自動完成、/help 輸出和 Telegram 機器人選單中。
簽名:ctx.register_command(name: str, handler: Callable, description: str = "", args_hint: str = "")
| 參數 | 型別 | 說明 |
|---|---|---|
name | str | 不含前導斜線的指令名稱(例如 "lcm"、"mystatus") |
handler | Callable[[str], str | None] | 呼叫時傳入原始參數字串。也可以是 async。 |
description | str | 顯示在 /help、自動完成和 Telegram 機器人選單中 |
與 register_cli_command() 的關鍵差異:
register_command() | register_cli_command() | |
|---|---|---|
| 呼叫方式 | Session 中的 /name | 終端機中的 hermes name |
| 運作環境 | CLI Session、Telegram、Discord 等 | 僅限終端機 |
| 處理器接收 | 原始 args 字串 | argparse Namespace |
| 使用情境 | 診斷、狀態、快速操作 | 複雜的子指令樹、設定精靈 |
**衝突保護:**如果外掛嘗試註冊一個與內建指令(help、model、new 等)衝突的名稱,註冊會被靜默拒絕並記錄警告。內建指令永遠優先。
**非同步處理器:**Gateway 派遣會自動偵測並等待非同步處理器,所以你可以使用同步或非同步函式:
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")
從斜線指令派遣工具
需要編排工具(透過 delegate_task 產生子代理、呼叫 file_edit 等)的斜線指令處理器應使用 ctx.dispatch_tool() 而非直接存取框架內部。父代理 context(工作區提示、旋轉器、模型繼承)會自動串接。
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
| 參數 | 型別 | 說明 |
|---|---|---|
name | str | 在工具註冊表中註冊的工具名稱(例如 "delegate_task"、"file_edit") |
args | dict | 工具參數,與模型傳送的格式相同 |
parent_agent | Agent | None | 選擇性覆蓋。省略時,從目前的 CLI 代理解析(或在 gateway 模式下優雅降級) |
執行時行為:
- CLI 模式:
parent_agent從活躍的 CLI 代理解析,所以工作區提示、旋轉器和模型選擇會如預期繼承。 - **Gateway 模式:**沒有 CLI 代理,所以工具會優雅降級——工作區從設定的終端機工作目錄讀取,不顯示旋轉器。
- **明確覆蓋:**如果呼叫者明確傳入
parent_agent=,會被尊重而不覆蓋。
這是從外掛指令派遣工具的公開、穩定介面。外掛不應存取 ctx._cli_ref.agent 或類似的私有狀態。
處理 Slack Block Kit 按鈕點擊
發送帶有互動元素(按鈕、溢出選單、日期選擇器等)的 Block Kit 訊息的外掛可以直接向 Slack 適配器註冊點擊處理器——不需要對 slack_bolt.AsyncApp 進行猴子補丁。
def register(ctx):
async def _on_approve(ack, body, action):
# ack within 3 seconds — slack_bolt requirement.
await ack()
# body["channel"]["id"], body["user"]["id"], body["message"]["ts"]
# action["action_id"], action["value"]
sweep_id = (action.get("value") or "").split("|", 1)[-1]
# ...do the deterministic work, then post a follow-up.
ctx.register_slack_action_handler("inbox_sweep_approve", _on_approve)
簽名:ctx.register_slack_action_handler(action_id, callback) -> None
| 參數 | 型別 | 說明 |
|---|---|---|
action_id | str | re.Pattern | dict | 任何 slack_bolt.App.action() 接受的內容:字面 action_id、匹配多個 id 的已編譯正則表達式,或約束 dict 如 {"action_id": "...", "block_id": "..."} |
callback | 非同步可呼叫 | 依照 slack_bolt 慣例接收 (ack, body, action) |
執行時行為:
- 處理器在外掛載入時排入佇列,並在 Slack 平台連線時串接到適配器的
slack_bolt.AsyncApp。 - 每個回調都以防禦方式包裝:如果你的處理器拋出例外,Gateway 會記錄錯誤並盡力 ack 點擊以免 Slack 重試。
- 標準 slack_bolt 規則適用——在 3 秒內
await ack(),然後執行較長的工作。 - 對於多工作區部署,處理器會對來自任何已連線工作區的點擊觸發;如果需要限定範圍,使用
body["team"]["id"]。
這是外掛參與 Slack 互動的公開方式。舊版外掛可能使用了補丁 SlackAdapter.connect;請優先使用此 API。
TIP
本指南涵蓋通用外掛(工具、鉤子、斜線指令、CLI 指令)。以下章節概述每種專用外掛類型的撰寫模式;每個都連結到其完整指南以取得欄位參考和範例。
專用外掛類型
Hermes 有五種超出通用介面的專用外掛類型。每個都作為目錄位於 plugins/<category>/<name>/(內建)或 ~/.hermes/plugins/<category>/<name>/(使用者)。合約依類別不同——選擇你需要的類型,然後閱讀其完整指南。
Model Provider 外掛——加入 LLM 後端
將 profile 放入 plugins/model-providers/<name>/:
# 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"),
))
# 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() 時延遲發現——auth.py、config.py、doctor.py、models.py、runtime_provider.py 和 chat_completions 傳輸會自動串接。使用者外掛按名稱覆蓋內建外掛。
完整指南:Model Provider Plugins—欄位參考、可覆蓋的鉤子(prepare_messages、build_extra_body、build_api_kwargs_extras、fetch_models)、api_mode 選擇、認證類型、測試。
Platform 外掛——加入 Gateway 頻道
將適配器放入 plugins/platforms/<name>/:
# 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"],
# Auto-populate PlatformConfig.extra from env so env-only setups
# show up in `hermes gateway status` without SDK instantiation.
env_enablement_fn=_env_enablement,
# Opt in to cron delivery: `deliver=myplatform` routes to this var.
cron_deliver_env_var="MYPLATFORM_HOME_CHANNEL",
emoji="💬",
platform_hint="You are chatting via MyPlatform. Keep responses concise.",
)
# plugins/platforms/myplatform/plugin.yaml
name: myplatform-platform
label: MyPlatform
kind: platform
version: 1.0.0
description: MyPlatform gateway adapter
requires_env:
- name: MYPLATFORM_TOKEN
description: "Bot token from the MyPlatform console"
password: true
optional_env:
- name: MYPLATFORM_HOME_CHANNEL
description: "Default channel for cron delivery"
password: false
完整指南:Adding Platform Adapters—完整的 BasePlatformAdapter 合約、訊息路由、認證門檻、設定精靈整合。參見 plugins/platforms/irc/ 取得一個僅使用標準函式庫的運作範例。
Memory Provider 外掛——加入跨 Session 的知識後端
將 MemoryProvider 的實作放入 plugins/memory/<name>/:
# 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_content, assistant_content, *,
session_id="", messages=None) -> None:
...
def prefetch(self, query, *, session_id="") -> str:
...
def get_tool_schemas(self) -> list[dict]:
return [] # required @abstractmethod — see full guide
def register(ctx):
ctx.register_memory_provider(MyMemoryProvider())
記憶供應商是單選的——一次只能有一個啟用,透過 config.yaml 中的 memory.provider 選擇。
完整指南:Memory Provider Plugins—完整的 MemoryProvider ABC、執行緒合約、Profile 隔離、透過 cli.py 的 CLI 指令註冊。
Context Engine 外掛——替換 context 壓縮器
# 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 update_from_response(self, usage) -> None: ...
def should_compress(self, prompt_tokens: int = None) -> bool: ...
def compress(self, messages, current_tokens=None, focus_topic=None) -> list: ...
def register(ctx):
ctx.register_context_engine(MyContextEngine())
Context 引擎是單選的——透過 config.yaml 中的 context.engine 選擇。
完整指南:Context Engine Plugins。
圖片生成後端
將供應商放入 plugins/image_gen/<name>/:
# 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, aspect_ratio="landscape", **kwargs) -> dict:
# returns success_response(...) / error_response(...)
...
def register(ctx):
ctx.register_image_gen_provider(MyImageGenProvider())
# plugins/image_gen/my-imggen/plugin.yaml
name: my-imggen
kind: backend
version: 1.0.0
description: Custom image generation backend
完整指南:Image Generation Provider Plugins—完整的 ImageGenProvider ABC、list_models() / get_setup_schema() 元資料、success_response() / error_response() 輔助函式、base64 vs URL 輸出、使用者覆蓋、pip 分發。
參考範例:plugins/image_gen/openai/(透過 OpenAI SDK 的 DALL-E / GPT-Image)、plugins/image_gen/openai-codex/、plugins/image_gen/xai/(Grok 圖片生成)。
非 Python 擴充介面
Hermes 也接受完全不是 Python 外掛的擴充。這些在可插拔介面對照表中列出;以下章節簡述每種撰寫風格。
MCP 伺服器——註冊外部工具
Model Context Protocol (MCP) 伺服器可以將自己的工具註冊到 Hermes 中,不需要任何 Python 外掛。在 ~/.hermes/config.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 在啟動時連線到每個伺服器,列出其工具,並與內建工具一起註冊。LLM 看到它們就像任何其他工具一樣。完整指南:MCP。
Gateway 事件鉤子——在生命週期事件時觸發
將 manifest + 處理器放入 ~/.hermes/hooks/<name>/:
# ~/.hermes/hooks/long-task-alert/HOOK.yaml
name: long-task-alert
description: Send a push notification when a long task finishes
events:
- agent:end
# ~/.hermes/hooks/long-task-alert/handler.py
async def handle(event_type: str, context: dict) -> None:
if context.get("duration_seconds", 0) > 120:
# send notification …
pass
事件包括 gateway:startup、session:start、session:end、session:reset、agent:start、agent:step、agent:end 和萬用字元 command:*。鉤子中的錯誤會被捕獲並記錄——永遠不會阻塞主管線。
完整指南:Gateway Event Hooks。
Shell 鉤子——在工具呼叫時執行 shell 指令
如果你只想在工具觸發時執行一個腳本(通知、稽核日誌、桌面提醒、自動格式化),在 config.yaml 中使用 shell 鉤子——不需要 Python:
hooks:
- event: post_tool_call
command: "notify-send 'Tool ran: {tool_name}'"
when:
tools: [terminal, patch, write_file]
支援與 Python 外掛鉤子相同的所有事件(pre_tool_call、post_tool_call、pre_llm_call、post_llm_call、on_session_start、on_session_end、pre_gateway_dispatch),加上用於 pre_tool_call 封鎖決策的結構化 JSON 輸出。
完整指南:Shell Hooks。
技能來源——加入自訂技能註冊表
如果你維護一個 GitHub 技能儲存庫(或想從內建來源之外的社群索引拉取),將它加入為一個 tap:
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 儲存庫——不需要伺服器或註冊表帳號。
完整指南:Skills Hub·Publishing a custom tap(儲存庫結構、最小範例、非預設路徑、信任等級)。
透過指令範本的 TTS / STT
任何讀寫音訊或文字的 CLI 都可以透過 config.yaml 接入——不需要 Python 程式碼:
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)。任何與路徑互動的 CLI 都會自動成為一個外掛。
完整指南:TTS custom command providers·STT。
透過 pip 分發
要公開分享外掛,在你的 Python 套件中加入一個 entry point:
# pyproject.toml
[project.entry-points."hermes_agent.plugins"]
my-plugin = "my_plugin_package"
pip install hermes-plugin-calculator
# 外掛在下次 hermes 啟動時自動發現
為 NixOS 分發
NixOS 使用者可以透過宣告式方式安裝你的外掛,前提是你提供帶有 entry points 的 pyproject.toml:
Entry-point 外掛(推薦用於分發):
# 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):
services.hermes-agent.extraPlugins = [
(pkgs.fetchFromGitHub {
owner = "you";
repo = "hermes-my-plugin";
rev = "v1.0.0";
hash = "sha256-...";
})
];
完整文件請見 Nix 設定指南,包括 overlay 用法和衝突檢查。
常見錯誤
處理器沒有回傳 JSON 字串:
# 錯誤——回傳 dict
def handler(args, **kwargs):
return {"result": 42}
# 正確——回傳 JSON 字串
def handler(args, **kwargs):
return json.dumps({"result": 42})
處理器簽名缺少 **kwargs:
# 錯誤——如果 Hermes 傳入額外 context 會出問題
def handler(args):
...
# 正確
def handler(args, **kwargs):
...
處理器拋出例外:
# 錯誤——例外傳播,工具呼叫失敗
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 描述太模糊:
# 不好——模型不知道何時使用
"description": "Does stuff"
# 好——模型確切知道何時和如何使用
"description": "Evaluate a mathematical expression. Use for arithmetic, trig, logarithms. Supports: +, -, *, /, **, sqrt, sin, cos, log, pi, e."