章節:核心功能 · 網址:https://hermesbible.com/docs/user-guide/features/hooks
Hermes 有三套 hook 系統,會在關鍵的生命週期節點執行自訂程式碼:
| 系統 | 註冊方式 | 執行環境 | 使用場景 |
|---|---|---|---|
| Gateway hooks | HOOK.yaml + handler.py,放在 ~/.hermes/hooks/ | 僅限 Gateway | 日誌紀錄、告警、webhook |
| Plugin hooks | 在 plugin 中使用 ctx.register_hook() | CLI + Gateway | 工具攔截、指標收集、守護規則 |
| Shell hooks | 在 ~/.hermes/config.yaml 的 hooks: 區塊中指定 shell 腳本路徑 | CLI + Gateway | 即插即用的擋截、自動格式化、上下文注入 |
三套系統都是非阻塞的 —— 任何 hook 中的錯誤都會被攔截並記錄,永遠不會讓 agent 崩潰。
Gateway Event Hooks
Gateway hooks 在 Gateway 運作期間(Telegram、Discord、Slack、WhatsApp、Teams)自動觸發,不會阻塞主要的 agent 管線。
建立一個 Hook
每個 hook 都是 ~/.hermes/hooks/ 下的一個目錄,包含兩個檔案:
~/.hermes/hooks/
└── my-hook/
├── HOOK.yaml # 聲明要監聽哪些事件
└── handler.py # Python handler 函式
HOOK.yaml
name: my-hook
description: 將所有 agent 活動記錄到檔案
events:
- agent:start
- agent:end
- agent:step
events 列表決定哪些事件會觸發你的 handler。你可以訂閱任意組合的事件,包含像 command:* 這樣的萬用字元。
handler.py
import json
from datetime import datetime
from pathlib import Path
LOG_FILE = Path.home() / ".hermes" / "hooks" / "my-hook" / "activity.log"
async def handle(event_type: str, context: dict):
"""Called for each subscribed event. Must be named 'handle'."""
entry = {
"timestamp": datetime.now().isoformat(),
"event": event_type,
**context,
}
with open(LOG_FILE, "a") as f:
f.write(json.dumps(entry) + "\n")
Handler 規則:
- 函式名稱必須是
handle - 接收
event_type(字串)和context(字典) - 可以用
async def或一般的def—— 兩種都可以 - 錯誤會被攔截並記錄,永遠不會讓 agent 崩潰
可用的事件
| 事件 | 觸發時機 | Context 鍵值 |
|---|---|---|
gateway:startup | Gateway 進程啟動 | platforms(已啟動的平台名稱列表) |
session:start | 建立新的訊息 session | platform、user_id、session_id、session_key |
session:end | Session 結束(在重置之前) | platform、user_id、session_key |
session:reset | 使用者執行 /new 或 /reset | platform、user_id、session_key |
agent:start | Agent 開始處理訊息 | platform、user_id、session_id、message |
agent:step | 工具呼叫迴圈的每次迭代 | platform、user_id、session_id、iteration、tool_names |
agent:end | Agent 處理完成 | platform、user_id、session_id、message、response |
command:* | 任何斜線指令執行時 | platform、user_id、command、args |
萬用字元比對
為 command:* 註冊的 handler 會對任何 command: 事件觸發(command:model、command:reset 等)。用單一訂閱就能監控所有斜線指令。
範例
Telegram 長任務告警
當 agent 執行超過 10 步時發送訊息給自己:
# ~/.hermes/hooks/long-task-alert/HOOK.yaml
name: long-task-alert
description: Alert when agent is taking many steps
events:
- agent:step
# ~/.hermes/hooks/long-task-alert/handler.py
import os
import httpx
THRESHOLD = 10
BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
CHAT_ID = os.getenv("TELEGRAM_HOME_CHANNEL")
async def handle(event_type: str, context: dict):
iteration = context.get("iteration", 0)
if iteration == THRESHOLD and BOT_TOKEN and CHAT_ID:
tools = ", ".join(context.get("tool_names", []))
text = f"⚠️ Agent has been running for {iteration} steps. Last tools: {tools}"
async with httpx.AsyncClient() as client:
await client.post(
f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage",
json={"chat_id": CHAT_ID, "text": text},
)
指令使用日誌
追蹤哪些斜線指令被使用:
# ~/.hermes/hooks/command-logger/HOOK.yaml
name: command-logger
description: Log slash command usage
events:
- command:*
# ~/.hermes/hooks/command-logger/handler.py
import json
from datetime import datetime
from pathlib import Path
LOG = Path.home() / ".hermes" / "logs" / "command_usage.jsonl"
def handle(event_type: str, context: dict):
LOG.parent.mkdir(parents=True, exist_ok=True)
entry = {
"ts": datetime.now().isoformat(),
"command": context.get("command"),
"args": context.get("args"),
"platform": context.get("platform"),
"user": context.get("user_id"),
}
with open(LOG, "a") as f:
f.write(json.dumps(entry) + "\n")
Session 啟動 Webhook
新 session 建立時 POST 到外部服務:
# ~/.hermes/hooks/session-webhook/HOOK.yaml
name: session-webhook
description: Notify external service on new sessions
events:
- session:start
- session:reset
# ~/.hermes/hooks/session-webhook/handler.py
import httpx
WEBHOOK_URL = "https://your-service.example.com/hermes-events"
async def handle(event_type: str, context: dict):
async with httpx.AsyncClient() as client:
await client.post(WEBHOOK_URL, json={
"event": event_type,
**context,
}, timeout=5)
教學:BOOT.md — 每次 Gateway 啟動時執行開機檢查清單
社群中流行的模式:在 ~/.hermes/BOOT.md 放一個 Markdown 檢查清單,讓 agent 每次 Gateway 啟動時執行一次。適用於「每次開機時,檢查昨晚的排程作業是否失敗,如果有就透過 Discord 通知我」,或是「摘要過去 24 小時的 deploy.log 並貼到 Slack #ops」等情境。
本教學展示如何自己打造一個使用者自訂 hook。Hermes 並未內建 BOOT.md hook —— 你需要自行實作想要的行為。
我們要建什麼
- 一個位於
~/.hermes/BOOT.md的自然語言啟動指令檔案。 - 一個在
gateway:startup時觸發的 Gateway hook,使用你的 Gateway 所設定的模型/憑證來啟動一次性 agent,並執行 BOOT.md 中的指令。 - 一個
[SILENT]約定,讓 agent 在沒有需要報告的事項時可以選擇不發送訊息。
步驟 1:撰寫你的檢查清單
建立 ~/.hermes/BOOT.md。像在給人類助理下指令一樣撰寫:
# Startup Checklist
1. Run `hermes cron list` and check if any scheduled jobs failed overnight.
2. If any failed, send a summary to Discord #ops using the `send_message` tool.
3. Check if `/opt/app/deploy.log` has any ERROR lines from the last 24 hours. If yes, summarize them and include in the same Discord message.
4. If nothing went wrong, reply with only `[SILENT]` so no message is sent.
Agent 會將這些內容視為 prompt 的一部分,所以你能用自然語言描述的任何事情都有效 —— 工具呼叫、shell 指令、發送訊息、摘要檔案等。
步驟 2:建立 hook
~/.hermes/hooks/boot-md/
├── HOOK.yaml
└── handler.py
~/.hermes/hooks/boot-md/HOOK.yaml
name: boot-md
description: Run ~/.hermes/BOOT.md on gateway startup
events:
- gateway:startup
~/.hermes/hooks/boot-md/handler.py
"""Run ~/.hermes/BOOT.md on every gateway startup."""
import logging
import threading
from pathlib import Path
logger = logging.getLogger("hooks.boot-md")
BOOT_FILE = Path.home() / ".hermes" / "BOOT.md"
def _build_prompt(content: str) -> str:
return (
"You are running a startup boot checklist. Follow the instructions "
"below exactly.\n\n"
"---\n"
f"{content}\n"
"---\n\n"
"Execute each instruction. Use the send_message tool to deliver any "
"messages to platforms like Discord or Slack.\n"
"If nothing needs attention and there is nothing to report, reply "
"with ONLY: [SILENT]"
)
def _run_boot_agent(content: str) -> None:
"""Spawn a one-shot agent and execute the checklist.
Uses the gateway's resolved model and runtime credentials so this works
against custom endpoints, aggregators, and OAuth-based providers alike.
"""
try:
from gateway.run import _resolve_gateway_model, _resolve_runtime_agent_kwargs
from run_agent import AIAgent
agent = AIAgent(
model=_resolve_gateway_model(),
**_resolve_runtime_agent_kwargs(),
platform="gateway",
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
max_iterations=20,
)
result = agent.run_conversation(_build_prompt(content))
response = (result.get("final_response", "") or "").strip()
if response.upper() not in {"[SILENT]", "SILENT", "NO_REPLY", "NO REPLY"}:
logger.info("boot-md completed: %s", response[:200])
else:
logger.info("boot-md completed (nothing to report)")
except Exception as e:
logger.error("boot-md agent failed: %s", e)
async def handle(event_type: str, context: dict) -> None:
if not BOOT_FILE.exists():
return
content = BOOT_FILE.read_text(encoding="utf-8").strip()
if not content:
return
logger.info("Running BOOT.md (%d chars)", len(content))
# Background thread so gateway startup isn't blocked on a full agent turn.
thread = threading.Thread(
target=_run_boot_agent,
args=(content,),
name="boot-md",
daemon=True,
)
thread.start()
兩個關鍵行:
_resolve_gateway_model()讀取 Gateway 目前設定的模型。_resolve_runtime_agent_kwargs()以與正常 Gateway 互動相同的方式解析供應商憑證 —— 包含 API 金鑰、base URL、OAuth token 和憑證池。
如果沒有這些,裸的 AIAgent() 會退回內建預設值,對任何非預設端點都會收到 401 錯誤。
步驟 3:測試
重啟 Gateway:
hermes gateway restart
觀察日誌:
hermes logs --follow --level INFO | grep boot-md
你應該會看到 Running BOOT.md (N chars),接著是 boot-md completed: ...(agent 執行的摘要)或 boot-md completed (nothing to report)(agent 回覆了精確的靜默 token 如 [SILENT])。
刪除 ~/.hermes/BOOT.md 即可停用檢查清單 —— hook 仍會載入,但當檔案不存在時會靜默跳過。
擴展此模式
- 排程感知的檢查清單: 在 BOOT.md 的指令中使用
datetime.now().weekday()(「如果是週一,也檢查每週的部署日誌」)。指令是自由格式的文字,所以 agent 能推理的任何事情都適用。 - 多個檢查清單: 將 hook 指向不同的檔案(
STARTUP.md、MORNING.md等),並為每個建立獨立的 hook 目錄。 - 非 agent 變體: 如果不需要完整的 agent 迴圈,可以完全跳過
AIAgent,讓 handler 直接透過httpx發送固定的通知。更便宜、更快,而且不依賴任何供應商。
為什麼這不是內建功能
Hermes 的早期版本曾將此作為內建 hook,並在每次 Gateway 啟動時用裸預設值靜默地啟動 agent。這對使用自訂端點的使用者造成了困擾,也讓不知道它在運作的使用者完全看不到這個功能。將它保留為一個有文件的模式 —— 由你自己在你的 hooks 目錄中建立 —— 意味著你能清楚看到它的行為,並透過撰寫檔案來主動啟用。
運作原理
- Gateway 啟動時,
HookRegistry.discover_and_load()掃描~/.hermes/hooks/ - 每個包含
HOOK.yaml+handler.py的子目錄會被動態載入 - Handler 會針對它們聲明的事件進行註冊
- 在每個生命週期節點,
hooks.emit()觸發所有匹配的 handler - 任何 handler 中的錯誤都會被攔截並記錄 —— 故障的 hook 永遠不會讓 agent 崩潰
INFO
Gateway hooks 僅在 Gateway(Telegram、Discord、Slack、WhatsApp、Teams)中觸發。CLI 不會載入 Gateway hooks。若需要在所有環境中運作的 hook,請使用 plugin hooks。
Plugin Hooks
Plugin 可以註冊在 CLI 和 Gateway 中都會觸發的 hook。這些 hook 透過 plugin 的 register() 函式中以程式化方式 ctx.register_hook() 註冊。
有關 plugin 打包和註冊的詳細資訊,請參閱 Plugin 指南。
def register(ctx):
ctx.register_hook("pre_tool_call", my_tool_observer)
ctx.register_hook("post_tool_call", my_tool_logger)
ctx.register_hook("pre_llm_call", my_memory_callback)
ctx.register_hook("post_llm_call", my_sync_callback)
ctx.register_hook("on_session_start", my_init_callback)
ctx.register_hook("on_session_end", my_cleanup_callback)
所有 hook 的通用規則:
- Callback 接收具名參數。始終接受
**kwargs以確保向前相容 —— 未來版本可能會新增參數而不會破壞你的 plugin。 - 如果 callback 崩潰,會被記錄並跳過。其他 hook 和 agent 會正常繼續運作。有問題的 plugin 永遠不會讓 agent 崩潰。
- 有兩個 hook 的回傳值會影響行為:
pre_tool_call可以擋截工具呼叫,pre_llm_call可以在 LLM 呼叫中注入上下文。其他所有 hook 都是發射後不管的觀察者。 - Observer callback 會自動接收
telemetry_schema_version。當存在時,turn_id、api_request_id、task_id、session_id和api_call_count是獨立的關聯欄位。將api_request_id視為不透明的識別碼;不要剖析它的字串格式。
快速參考
| Hook | 觸發時機 | 回傳值 |
|---|---|---|
pre_tool_call | 任何工具執行之前 | {"action": "block", "message": str} 以否決呼叫 |
post_tool_call | 任何工具回傳之後 | 忽略 |
pre_llm_call | 每個回合一次,在工具呼叫迴圈之前 | {"context": str} 以在使用者訊息前插入上下文 |
post_llm_call | 每個回合一次,在工具呼叫迴圈之後 | 忽略 |
on_session_start | 新 session 建立(僅限第一回合) | 忽略 |
on_session_end | Session 結束 | 忽略 |
on_session_finalize | CLI/Gateway 清除 active session(flush、save、stats) | 忽略 |
on_session_reset | Gateway 替換為新的 session key(例如 /new、/reset) | 忽略 |
subagent_stop | delegate_task 的子 agent 退出 | 忽略 |
pre_gateway_dispatch | Gateway 收到使用者訊息,在 auth + dispatch 之前 | {"action": "skip" | "rewrite" | "allow", ...} 以影響流程 |
pre_approval_request | 危險指令需要使用者核准,在提示/通知發送之前 | 忽略 |
post_approval_response | 使用者回應了核准提示(或逾時) | 忽略 |
transform_tool_result | 工具回傳後,結果交還給模型之前 | str 替換結果,None 不變更 |
transform_terminal_output | terminal 工具內部,在截斷/ANSI 清除/遮蔽之前 | str 替換原始輸出,None 不變更 |
transform_llm_output | 工具呼叫迴圈完成後,最終回應送達使用者之前 | str 替換回應文字,None/空字串不變更 |
pre_tool_call
在每次工具執行之前立即觸發 —— 包含內建工具和 plugin 工具。
Callback 簽名:
def my_callback(tool_name: str, args: dict, task_id: str, **kwargs):
| 參數 | 型別 | 描述 |
|---|---|---|
tool_name | str | 即將執行的工具名稱(例如 "terminal"、"web_search"、"read_file") |
args | dict | 模型傳給工具的參數 |
task_id | str | Session/task 識別碼。若未設定則為空字串。 |
觸發時機: 在 model_tools.py 的 handle_function_call() 內部,工具的 handler 執行之前。每次工具呼叫觸發一次 —— 如果模型並行呼叫 3 個工具,則觸發 3 次。
回傳值 —— 否決呼叫:
return {"action": "block", "message": "Reason the tool call was blocked"}
Agent 會以 message 作為錯誤訊息回傳給模型,並跳過該工具。第一個匹配的擋截指令會生效(Python plugin 先註冊的優先,然後是 shell hook)。其他任何回傳值都會被忽略,所以現有的觀察者 callback 會繼續正常運作。
使用場景: 日誌紀錄、審計軌跡、工具呼叫計數、擋截危險操作、速率限制、per-user 策略執行。
範例 — 工具呼叫審計日誌:
import json, logging
from datetime import datetime
logger = logging.getLogger(__name__)
def audit_tool_call(tool_name, args, task_id, **kwargs):
logger.info("TOOL_CALL session=%s tool=%s args=%s",
task_id, tool_name, json.dumps(args)[:200])
def register(ctx):
ctx.register_hook("pre_tool_call", audit_tool_call)
範例 — 危險工具警告:
DANGEROUS = {"terminal", "write_file", "patch"}
def warn_dangerous(tool_name, **kwargs):
if tool_name in DANGEROUS:
print(f"⚠ Executing potentially dangerous tool: {tool_name}")
def register(ctx):
ctx.register_hook("pre_tool_call", warn_dangerous)
post_tool_call
在每次工具執行回傳之後立即觸發。
Callback 簽名:
def my_callback(tool_name: str, args: dict, result: str, task_id: str,
duration_ms: int, **kwargs):
| 參數 | 型別 | 描述 |
|---|---|---|
tool_name | str | 剛執行完的工具名稱 |
args | dict | 模型傳給工具的參數 |
result | str | 工具的回傳值(永遠是 JSON 字串) |
task_id | str | Session/task 識別碼。若未設定則為空字串。 |
duration_ms | int | 工具派發花費的時間,以毫秒為單位(使用 time.monotonic() 在 registry.dispatch() 前後測量)。 |
觸發時機: 在 model_tools.py 的 handle_function_call() 內部,工具的 handler 回傳之後。每次工具呼叫觸發一次。如果工具拋出未處理的例外,不會觸發(錯誤會被攔截並以 JSON 錯誤字串回傳,post_tool_call 會以該錯誤字串作為 result 觸發)。
回傳值: 忽略。
使用場景: 記錄工具結果、收集指標、追蹤工具成功/失敗率、延遲儀表板、per-tool 預算告警、特定工具完成時發送通知。
範例 — 追蹤工具使用指標:
from collections import Counter, defaultdict
import json
_tool_counts = Counter()
_error_counts = Counter()
_latency_ms = defaultdict(list)
def track_metrics(tool_name, result, duration_ms=0, **kwargs):
_tool_counts[tool_name] += 1
_latency_ms[tool_name].append(duration_ms)
try:
parsed = json.loads(result)
if "error" in parsed:
_error_counts[tool_name] += 1
except (json.JSONDecodeError, TypeError):
pass
def register(ctx):
ctx.register_hook("post_tool_call", track_metrics)
pre_llm_call
每個回合一次,在工具呼叫迴圈開始之前觸發。這是唯一一個回傳值會被使用的 hook —— 它可以將上下文注入到當前回合的使用者訊息中。
Callback 簽名:
def my_callback(session_id: str, user_message: str, conversation_history: list,
is_first_turn: bool, model: str, platform: str, **kwargs):
| 參數 | 型別 | 描述 |
|---|---|---|
session_id | str | 當前 session 的唯一識別碼 |
user_message | str | 使用者在這個回合的原始訊息(在任何 skill 注入之前) |
conversation_history | list | 完整訊息列表的副本(OpenAI 格式:[{"role": "user", "content": "..."}]) |
is_first_turn | bool | 如果是新 session 的第一回合為 True,後續回合為 False |
model | str | 模型識別碼(例如 "anthropic/claude-sonnet-4.6") |
platform | str | Session 執行的位置:"cli"、"telegram"、"discord" 等。 |
觸發時機: 在 run_agent.py 的 run_conversation() 內部,上下文壓縮之後但在主要 while 迴圈之前。每次 run_conversation() 呼叫觸發一次(即每個使用者回合一次),不是在工具迴圈內的每次 API 呼叫都觸發。
回傳值: 如果 callback 回傳一個帶有 "context" 鍵的字典,或一個非空的純字串,該文字會被附加到當前回合的使用者訊息中。回傳 None 則不注入。
# 注入上下文
return {"context": "Recalled memories:\n- User likes Python\n- Working on hermes-agent"}
# 純字串(等效)
return "Recalled memories:\n- User likes Python"
# 不注入
return None
注入位置: 永遠是使用者訊息,永遠不是系統 prompt。這保留了 prompt 快取 —— 系統 prompt 在各回合之間保持相同,所以快取的 token 會被重用。系統 prompt 是 Hermes 的領域(模型引導、工具執行規則、個性、技能)。Plugin 在使用者輸入旁邊貢獻上下文。
所有注入的上下文都是暫時的 —— 僅在 API 呼叫時新增。對話歷史中的原始使用者訊息永遠不會被修改,也不會被持久化到 session 資料庫。
當多個 plugin 回傳上下文時,它們的輸出會以雙換行符連接,連接順序依 plugin 發現順序(按目錄名稱的字母順序)。
使用場景: 記憶回憶、RAG 上下文注入、守護規則、per-turn 分析。
範例 — 記憶回憶:
import httpx
MEMORY_API = "https://your-memory-api.example.com"
def recall(session_id, user_message, is_first_turn, **kwargs):
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:\n" + "\n".join(f"- {m['text']}" for m in memories)
return {"context": text}
except Exception:
return None
def register(ctx):
ctx.register_hook("pre_llm_call", recall)
範例 — 守護規則:
POLICY = "Never execute commands that delete files without explicit user confirmation."
def guardrails(**kwargs):
return {"context": POLICY}
def register(ctx):
ctx.register_hook("pre_llm_call", guardrails)
post_llm_call
每個回合一次,在工具呼叫迴圈完成且 agent 產生最終回應後觸發。僅在成功的回合觸發 —— 如果回合被中斷則不會觸發。
Callback 簽名:
def my_callback(session_id: str, user_message: str, assistant_response: str,
conversation_history: list, model: str, platform: str, **kwargs):
| 參數 | 型別 | 描述 |
|---|---|---|
session_id | str | 當前 session 的唯一識別碼 |
user_message | str | 使用者在這個回合的原始訊息 |
assistant_response | str | Agent 在這個回合的最終文字回應 |
conversation_history | list | 回合完成後完整訊息列表的副本 |
model | str | 模型識別碼 |
platform | str | Session 執行的位置 |
觸發時機: 在 run_agent.py 的 run_conversation() 內部,工具迴圈以最終回應退出後。由 if final_response and not interrupted 守護 —— 所以當使用者在回合中途中斷或 agent 達到迭代上限而沒有產生回應時,不會觸發。
回傳值: 忽略。
使用場景: 將對話資料同步到外部記憶系統、計算回應品質指標、記錄回合摘要、觸發後續動作。
範例 — 同步到外部記憶:
import httpx
MEMORY_API = "https://your-memory-api.example.com"
def sync_memory(session_id, user_message, assistant_response, **kwargs):
try:
httpx.post(f"{MEMORY_API}/store", json={
"session_id": session_id,
"user": user_message,
"assistant": assistant_response,
}, timeout=5)
except Exception:
pass # best-effort
def register(ctx):
ctx.register_hook("post_llm_call", sync_memory)
範例 — 追蹤回應長度:
import logging
logger = logging.getLogger(__name__)
def log_response_length(session_id, assistant_response, model, **kwargs):
logger.info("RESPONSE session=%s model=%s chars=%d",
session_id, model, len(assistant_response or ""))
def register(ctx):
ctx.register_hook("post_llm_call", log_response_length)
on_session_start
在全新 session 建立時觸發一次。在 session 續用時(使用者在現有 session 中發送第二則訊息)不會觸發。
Callback 簽名:
def my_callback(session_id: str, model: str, platform: str, **kwargs):
| 參數 | 型別 | 描述 |
|---|---|---|
session_id | str | 新 session 的唯一識別碼 |
model | str | 模型識別碼 |
platform | str | Session 執行的位置 |
觸發時機: 在 run_agent.py 的 run_conversation() 內部,在新 session 的第一回合期間 —— 具體來說是在系統 prompt 建立之後但在工具迴圈開始之前。檢查方式是 if not conversation_history(沒有先前訊息 = 新 session)。
回傳值: 忽略。
使用場景: 初始化 session 範圍的狀態、預熱快取、向外部服務註冊 session、記錄 session 開始。
範例 — 初始化 session 快取:
_session_caches = {}
def init_session(session_id, model, platform, **kwargs):
_session_caches[session_id] = {
"model": model,
"platform": platform,
"tool_calls": 0,
"started": __import__("datetime").datetime.now().isoformat(),
}
def register(ctx):
ctx.register_hook("on_session_start", init_session)
on_session_end
在每次 run_conversation() 呼叫的最末尾觸發,不論結果如何。如果使用者在 agent 執行中途離開,也會從 CLI 的退出處理常式中觸發。
Callback 簽名:
def my_callback(session_id: str, completed: bool, interrupted: bool,
model: str, platform: str, **kwargs):
| 參數 | 型別 | 描述 |
|---|---|---|
session_id | str | Session 的唯一識別碼 |
completed | bool | 如果 agent 產生了最終回應則為 True,否則為 False |
interrupted | bool | 如果回合被中斷則為 True(使用者發送新訊息、/stop 或離開) |
model | str | 模型識別碼 |
platform | str | Session 執行的位置 |
觸發時機: 在兩個地方:
run_agent.py—— 在每次run_conversation()呼叫的末尾,所有清除工作完成後。永遠會觸發,即使回合出錯。cli.py—— 在 CLI 的 atexit 處理常式中,但僅限 agent 在退出時正在執行中(_agent_running=True)。這涵蓋了在處理過程中按 Ctrl+C 和/exit的情況。此時completed=False且interrupted=True。
回傳值: 忽略。
使用場景: 清空緩衝區、關閉連線、持久化 session 狀態、記錄 session 持續時間、清除在 on_session_start 中初始化的資源。
範例 — 清空和清除:
_session_caches = {}
def cleanup_session(session_id, completed, interrupted, **kwargs):
cache = _session_caches.pop(session_id, None)
if cache:
# Flush accumulated data to disk or external service
status = "completed" if completed else ("interrupted" if interrupted else "failed")
print(f"Session {session_id} ended: {status}, {cache['tool_calls']} tool calls")
def register(ctx):
ctx.register_hook("on_session_end", cleanup_session)
範例 — Session 持續時間追蹤:
import time, logging
logger = logging.getLogger(__name__)
_start_times = {}
def on_start(session_id, **kwargs):
_start_times[session_id] = time.time()
def on_end(session_id, completed, interrupted, **kwargs):
start = _start_times.pop(session_id, None)
if start:
duration = time.time() - start
logger.info("SESSION_DURATION session=%s seconds=%.1f completed=%s interrupted=%s",
session_id, duration, completed, interrupted)
def register(ctx):
ctx.register_hook("on_session_start", on_start)
ctx.register_hook("on_session_end", on_end)
on_session_finalize
當 CLI 或 Gateway 清除一個 active session 時觸發 —— 例如使用者執行 /new、Gateway 垃圾回收了一個閒置的 session,或 CLI 在有 active agent 時退出。這是清除與即將離開的 session 關聯的狀態的最後機會。
Callback 簽名:
def my_callback(session_id: str | None, platform: str, **kwargs):
| 參數 | 型別 | 描述 |
|---|---|---|
session_id | str 或 None | 即將離開的 session ID。如果不存在 active session,可能是 None。 |
platform | str | "cli" 或訊息平台名稱("telegram"、"discord" 等)。 |
觸發時機: 在 cli.py(在 /new / CLI 退出時)和 gateway/run.py(session 被重置或垃圾回收時)。在 Gateway 端永遠與 on_session_reset 成對觸發。
回傳值: 忽略。
使用場景: 在 session ID 被捨棄前持久化最終 session 指標、關閉 per-session 資源、發送最終遙測事件、排空佇列中的寫入。
on_session_reset
當 Gateway 為一個 active chat替換為新的 session key 時觸發 —— 使用者呼叫了 /new、/reset、/clear,或 adapter 在閒置時間後選取了新的 session。這讓 plugin 能在對話狀態被清除時做出反應,而不需要等到下一個 on_session_start。
Callback 簽名:
def my_callback(session_id: str, platform: str, **kwargs):
| 參數 | 型別 | 描述 |
|---|---|---|
session_id | str | 新 session 的 ID(已旋轉為新的值)。 |
platform | str | 訊息平台名稱。 |
觸發時機: 在 gateway/run.py 中,新的 session key 分配後立即觸發,但在下一個入站訊息被處理之前。在 Gateway 上,順序是:on_session_finalize(old_id) → 替換 → on_session_reset(new_id) → 第一個入站回合的 on_session_start(new_id)。
回傳值: 忽略。
使用場景: 重置以 session_id 為鍵的 per-session 快取、發送「session 已旋轉」的分析事件、初始化新的狀態儲存桶。
參閱 Build a Plugin 指南 以取得完整的操作說明,包含工具 schema、handler 和進階 hook 模式。
subagent_stop
在 delegate_task 完成後,每個子 agent 觸發一次。不論你委派了一個任務還是三個批次任務,這個 hook 都會為每個子 agent 觸發一次,序列化在父執行緒上。
Callback 簽名:
def my_callback(parent_session_id: str, child_role: str | None,
child_summary: str | None, child_status: str,
duration_ms: int, **kwargs):
| 參數 | 型別 | 描述 |
|---|---|---|
parent_session_id | str | 委派父 agent 的 Session ID |
child_role | str | None | 在子 agent 上設定的 orchestrator 角色標籤(如果功能未啟用則為 None) |
child_summary | str | None | 子 agent 回傳給父 agent 的最終回應 |
child_status | str | "completed"、"failed"、"interrupted" 或 "error" |
duration_ms | int | 執行子 agent 花費的實際時間,以毫秒為單位 |
觸發時機: 在 tools/delegate_tool.py 中,ThreadPoolExecutor.as_completed() 排空所有子 future 之後。觸發被序列化到父執行緒,所以 hook 作者不需要考慮並行 callback 執行的問題。
回傳值: 忽略。
使用場景: 記錄 orchestrator 活動、累積子 agent 持續時間用於計費、寫入委派後審計記錄。
範例 — 記錄 orchestrator 活動:
import logging
logger = logging.getLogger(__name__)
def log_subagent(parent_session_id, child_role, child_status, duration_ms, **kwargs):
logger.info(
"SUBAGENT parent=%s role=%s status=%s duration_ms=%d",
parent_session_id, child_role, child_status, duration_ms,
)
def register(ctx):
ctx.register_hook("subagent_stop", log_subagent)
INFO
在大量委派的情況下(例如 orchestrator 角色 × 5 leaves × 嵌套深度),
subagent_stop每個回合會觸發很多次。保持你的 callback 執行快速;將耗時的工作推到背景佇列。
pre_gateway_dispatch
在 Gateway 中每個入站 MessageEvent 觸發一次,在內部事件守護之後但在 auth/pairing 和 agent 派發之前。這是 Gateway 級別訊息流程策略的攔截點(僅監聽視窗、人類接管、per-chat 路由等),這些策略不容易放入任何單一的平台 adapter 中。
Callback 簽名:
def my_callback(event, gateway, session_store, **kwargs):
| 參數 | 型別 | 描述 |
|---|---|---|
event | MessageEvent | 規格化的入站訊息(有 .text、.source、.message_id、.internal 等)。 |
gateway | GatewayRunner | Active 的 Gateway runner,plugin 可以呼叫 gateway.adapters[platform].send(...) 進行旁路回覆(owner 通知等)。 |
session_store | SessionStore | 透過 session_store.append_to_transcript(...) 進行靜默的對話記錄擷取。 |
觸發時機: 在 gateway/run.py 的 GatewayRunner._handle_message() 內部,is_internal 計算後立即觸發。內部事件會完全跳過此 hook(它們是系統生成的 —— 背景進程完成等 —— 不應被使用者端的策略守護)。
回傳值: None 或字典。第一個被識別的 action 字典會生效;其餘 plugin 結果會被忽略。Plugin callback 中的例外會被攔截並記錄;Gateway 在錯誤時永遠會穿透到正常派發。
| 回傳值 | 效果 |
|---|---|
{"action": "skip", "reason": "..."} | 丟棄訊息 —— 不會有 agent 回覆、不會有 pairing 流程、不會有 auth。Plugin 被假設已處理了它(例如靜默擷取到對話記錄中)。 |
{"action": "rewrite", "text": "new text"} | 替換 event.text,然後使用修改後的事件繼續正常派發。適用於將緩衝的環境訊息壓縮為單一提示。 |
{"action": "allow"} / None | 正常派發 —— 執行完整的 auth / pairing / agent 迴圈鏈。 |
使用場景: 僅監聽的群組聊天(僅在被提及時回應;將環境訊息緩衝到上下文中);人類接管(靜默擷取客戶訊息,同時由 owner 手動處理聊天);per-profile 速率限制;策略驅動的路由。
範例 — 靜默拒絕未授權的私訊,不觸發 pairing 碼:
def deny_unauthorized_dms(event, **kwargs):
src = event.source
if src.chat_type == "dm" and not _is_approved_user(src.user_id):
return {"action": "skip", "reason": "unauthorized-dm"}
return None
def register(ctx):
ctx.register_hook("pre_gateway_dispatch", deny_unauthorized_dms)
範例 — 在被提及時將環境訊息緩衝重寫為單一提示:
_buffers = {}
def buffer_or_rewrite(event, **kwargs):
key = (event.source.platform, event.source.chat_id)
buf = _buffers.setdefault(key, [])
if _bot_mentioned(event.text):
combined = "\n".join(buf + [event.text])
buf.clear()
return {"action": "rewrite", "text": combined}
buf.append(event.text)
return {"action": "skip", "reason": "ambient-buffered"}
def register(ctx):
ctx.register_hook("pre_gateway_dispatch", buffer_or_rewrite)
pre_approval_request
在核准請求顯示給使用者之前立即觸發 —— 涵蓋所有表面:互動式 CLI、Ink TUI、Gateway 平台(Telegram、Discord、Slack、WhatsApp、Matrix 等)和 ACP 客戶端(VS Code、Zed、JetBrains)。
這是串接自訂通知器的正確位置 —— 例如一個 macOS 選單列應用程式彈出允許/拒絕通知,或是一個記錄每個核准請求及其上下文的審計日誌。
Callback 簽名:
def my_callback(
command: str,
description: str,
pattern_key: str,
pattern_keys: list[str],
session_key: str,
surface: str,
**kwargs,
):
| 參數 | 型別 | 描述 |
|---|---|---|
command | str | 等待核准的 shell 指令 |
description | str | 指令被標記的人類可讀原因(多個 pattern 匹配時會合併) |
pattern_key | str | 觸發核准的主要 pattern key(例如 "rm_rf"、"sudo") |
pattern_keys | list[str] | 所有匹配的 pattern keys |
session_key | str | Session 識別碼,用於按聊天範圍化通知 |
surface | str | "cli" 代表互動式 CLI/TUI 提示,"gateway" 代表非同步平台核准 |
回傳值: 忽略。這裡的 hook 是純觀察者;它們無法否決或預先回答核准。若要在工具到達核准系統前擋截它,請使用 pre_tool_call。
使用場景: 桌面通知、推播告警、審計日誌、Slack webhook、升級路由、指標。
範例 — macOS 桌面通知:
import subprocess
def notify_approval(command, description, session_key, **kwargs):
title = "Hermes needs approval"
body = f"{description}: {command[:80]}"
subprocess.Popen([
"osascript", "-e",
f'display notification "{body}" with title "{title}"',
])
def register(ctx):
ctx.register_hook("pre_approval_request", notify_approval)
post_approval_response
在使用者回應核准提示(或提示逾時)之後觸發。
Callback 簽名:
def my_callback(
command: str,
description: str,
pattern_key: str,
pattern_keys: list[str],
session_key: str,
surface: str,
choice: str,
**kwargs,
):
與 pre_approval_request 相同的具名參數,加上:
| 參數 | 型別 | 描述 |
|---|---|---|
choice | str | "once"、"session"、"always"、"deny" 或 "timeout" 其中之一 |
回傳值: 忽略。
使用場景: 關閉對應的桌面通知、在審計日誌中記錄最終決策、更新指標、推進速率限制器。
def log_decision(command, choice, session_key, **kwargs):
logger.info("approval %s: %s for session %s", choice, command[:60], session_key)
def register(ctx):
ctx.register_hook("post_approval_response", log_decision)
transform_tool_result
在工具回傳後、結果被附加到對話之前觸發。讓 plugin 在模型看到結果之前,重寫任何工具的結果字串 —— 不僅是 terminal 輸出。
Callback 簽名:
def my_callback(
tool_name: str,
arguments: dict,
result: str,
task_id: str | None,
**kwargs,
) -> str | None:
| 參數 | 型別 | 描述 |
|---|---|---|
tool_name | str | 產生結果的工具(read_file、web_extract、delegate_task、…)。 |
arguments | dict | 模型呼叫工具時使用的參數。 |
result | str | 工具的原始結果字串,在截斷和 ANSI 清除之後。 |
task_id | str | None | 在 RL/基準測試環境中執行時的 Task/session ID。 |
回傳值: str 替換結果(回傳的字串就是模型看到的),None 不變更。
使用場景: 遮蔽 web_extract 輸出中的組織特定 PII、將冗長的 JSON 工具回應包裹在摘要標頭中、在 read_file 結果中注入檢索增強的提示、將 delegate_task 子 agent 報告重寫為專案特定的格式。
import re
SECRET = re.compile(r"sk-[A-Za-z0-9]{32,}")
def redact_secrets(tool_name, result, **kwargs):
if SECRET.search(result):
return SECRET.sub("[REDACTED]", result)
return None
def register(ctx):
ctx.register_hook("transform_tool_result", redact_secrets)
適用於所有工具。若僅需重寫 terminal 輸出,請參閱下方的 transform_terminal_output —— 它更窄且在管線中更早執行(截斷之前、遮蔽之前)。
transform_terminal_output
在 terminal 工具的前台輸出管線中觸發,在預設的 50 KB 截斷、ANSI 清除和密鑰遮蔽之前。讓 plugin 在任何下游處理接觸到之前,重寫 shell 指令的原始 stdout/stderr。
Callback 簽名:
def my_callback(
command: str,
output: str,
exit_code: int,
cwd: str,
task_id: str | None,
**kwargs,
) -> str | None:
| 參數 | 型別 | 描述 |
|---|---|---|
command | str | 產生輸出的 shell 指令。 |
output | str | 原始的合併 stdout/stderr(可能很大 —— 截斷在 hook 之後發生)。 |
exit_code | int | 進程退出碼。 |
cwd | str | 指令執行時的工作目錄。 |
回傳值: str 替換輸出,None 不變更。
使用場景: 為產生大量輸出的指令注入摘要(du -ah、find、tree),用專案特定的標記標註輸出讓下游 hook 知道如何處理,清除在各次執行間波動並破壞 prompt 快取的時間資訊。
def summarize_find(command, output, **kwargs):
if command.startswith("find ") and len(output) > 50_000:
lines = output.count("\n")
head = "\n".join(output.splitlines()[:40])
return f"{head}\n\n[summary: {lines} paths total, showing first 40]"
return None
def register(ctx):
ctx.register_hook("transform_terminal_output", summarize_find)
與 transform_tool_result(涵蓋所有其他工具)搭配使用效果很好。
transform_llm_output
在工具呼叫迴圈完成且模型產生最終回應後、在回應送達使用者(CLI、Gateway 或程式化呼叫者)之前,每個回合觸發一次。讓 plugin 使用傳統程式設計方法重寫助手的最終文字 —— 不需要在 SOUL 風味文字或 skill 驅動的轉換上消耗額外的推論 token。
Callback 簽名:
def my_callback(
response_text: str,
session_id: str,
model: str,
platform: str,
**kwargs,
) -> str | None:
| 參數 | 型別 | 描述 |
|---|---|---|
response_text | str | 助手在這個回合的最終回應文字。 |
session_id | str | 此對話的 Session ID(一次性執行可能為空)。 |
model | str | 產生回應的模型名稱(例如 anthropic/claude-sonnet-4.6)。 |
platform | str | 傳送平台(cli、telegram、discord、…;未設定時為空)。 |
回傳值: 非空 str 替換回應文字,None 或空字串不變更。當多個 plugin 註冊時,第一個非空字串會生效 —— 與 transform_tool_result 一致。
使用場景: 套用個性/詞彙轉換(海盜語、海綿寶寶),遮蔽最終文字中的使用者特定識別碼,附加專案特定的簽名頁尾,執行內部風格指南而不需要在 SOUL 指令上消耗 token。
import os, re
def spongebob(response_text, **kwargs):
if os.environ.get("SPONGEBOB_MODE") != "on":
return None # pass through unchanged
return re.sub(r"!", "!! Tartar sauce!", response_text)
def register(ctx):
ctx.register_hook("transform_llm_output", spongebob)
此 hook 以非空、未中斷的回應為守護條件 —— 在停止按鈕中斷或空回合時不會觸發。例外會以警告等級記錄,不會中斷 agent 執行。
Shell Hooks
在你的 cli-config.yaml 中宣告 shell 腳本 hook,Hermes 就會在對應的 plugin-hook 事件觸發時將它們作為子進程執行 —— 在 CLI 和 Gateway session 中皆適用。不需要撰寫 Python plugin。
使用 shell hook 當你需要一個即插即用的單檔腳本(Bash、Python、任何有 shebang 的東西)來:
- 擋截工具呼叫 —— 拒絕危險的
terminal指令、執行 per-directory 策略、要求核准破壞性的write_file/patch操作。 - 在工具呼叫後執行 —— 自動格式化 agent 剛寫的 Python 或 TypeScript 檔案、記錄 API 呼叫、觸發 CI 工作流程。
- 在下一個 LLM 回合注入上下文 —— 將
git status輸出、當前星期幾或檢索到的文件附加到使用者訊息(參閱pre_llm_call)。 - 觀察生命週期事件 —— 在子 agent 完成時(
subagent_stop)或 session 開始時(on_session_start)寫入一行日誌。
Shell hook 透過在 CLI 啟動(hermes_cli/main.py)和 Gateway 啟動(gateway/run.py)時呼叫 agent.shell_hooks.register_from_config(cfg) 來註冊。它們自然地與 Python plugin hook 組合 —— 兩者都通過相同的派發器。
比較一覽
| 維度 | Shell hooks | Plugin hooks | Gateway hooks |
|---|---|---|---|
| 宣告位置 | ~/.hermes/config.yaml 的 hooks: 區塊 | plugin.yaml plugin 中的 register() | HOOK.yaml + handler.py 目錄 |
| 檔案位置 | ~/.hermes/agent-hooks/(依慣例) | ~/.hermes/plugins/<name>/ | ~/.hermes/hooks/<name>/ |
| 語言 | 任何(Bash、Python、Go binary、…) | 僅限 Python | 僅限 Python |
| 執行環境 | CLI + Gateway | CLI + Gateway | 僅限 Gateway |
| 事件 | VALID_HOOKS(含 subagent_stop) | VALID_HOOKS | Gateway 生命週期(gateway:startup、agent:*、command:*) |
| 可擋截工具呼叫 | 是(pre_tool_call) | 是(pre_tool_call) | 否 |
| 可注入 LLM 上下文 | 是(pre_llm_call) | 是(pre_llm_call) | 否 |
| 授權同意 | 每個 (event, command) 配對的首次使用提示 | 隱式(Python plugin 信任) | 隱式(目錄信任) |
| 進程隔離 | 是(子進程) | 否(進程內) | 否(進程內) |
設定格式
hooks:
<event_name>: # 必須在 VALID_HOOKS 中
- matcher: "<regex>" # 選用;僅用於 pre/post_tool_call
command: "<shell command>" # 必填;透過 shlex.split 執行,shell=False
timeout: <seconds> # 選用;預設 60,上限 300
hooks_auto_accept: false # 請參閱下方「授權模型」
事件名稱必須是 plugin hook 事件其中之一;拼寫錯誤會產生 "Did you mean X?" 警告並被跳過。單一條目內的未知鍵會被忽略;缺少 command 會跳過並產生警告。timeout > 300 會被限制並產生警告。
JSON 通訊協定
每次事件觸發時,Hermes 為每個匹配的 hook(在 matcher 允許的情況下)產生一個子進程,將 JSON 載荷管道傳輸到 stdin,並從 stdout 讀取 JSON 回應。
stdin — 腳本接收的載荷:
{
"hook_event_name": "pre_tool_call",
"tool_name": "terminal",
"tool_input": {"command": "rm -rf /"},
"session_id": "sess_abc123",
"cwd": "/home/user/project",
"extra": {"task_id": "...", "tool_call_id": "..."}
}
tool_name 和 tool_input 在非工具事件(pre_llm_call、subagent_stop、session 生命週期)時為 null。extra 字典攜帶所有事件特定的具名參數(user_message、conversation_history、child_role、duration_ms、…)。無法序列化的值會被字串化而非省略。
stdout — 選擇性回應:
// 擋截 pre_tool_call(兩種格式皆可;內部會正規化):
{"decision": "block", "reason": "Forbidden: rm -rf"} // Claude-Code 風格
{"action": "block", "message": "Forbidden: rm -rf"} // Hermes 正規格式
// 為 pre_llm_call 注入上下文:
{"context": "Today is Friday, 2026-04-17"}
// 靜默無操作 — 任何空的 / 不匹配的輸出都可以:
格式錯誤的 JSON、非零退出碼和逾時會記錄警告,但永遠不會中斷 agent 迴圈。
實際範例
1. 每次寫入後自動格式化 Python 檔案
# ~/.hermes/config.yaml
hooks:
post_tool_call:
- matcher: "write_file|patch"
command: "~/.hermes/agent-hooks/auto-format.sh"
#!/usr/bin/env bash
# ~/.hermes/agent-hooks/auto-format.sh
payload="$(cat -)"
path=$(echo "$payload" | jq -r '.tool_input.path // empty')
[[ "$path" == *.py ]] && command -v black >/dev/null && black "$path" 2>/dev/null
printf '{}\n'
Agent 對檔案的 context 內視不會自動重新讀取 —— 重新格式化僅影響磁碟上的檔案。後續的 read_file 呼叫會讀取到格式化後的版本。
2. 擋截破壞性的 terminal 指令
hooks:
pre_tool_call:
- matcher: "terminal"
command: "~/.hermes/agent-hooks/block-rm-rf.sh"
timeout: 5
#!/usr/bin/env bash
# ~/.hermes/agent-hooks/block-rm-rf.sh
payload="$(cat -)"
cmd=$(echo "$payload" | jq -r '.tool_input.command // empty')
if echo "$cmd" | grep -qE 'rm[[:space:]]+-rf?[[:space:]]+/'; then
printf '{"decision": "block", "reason": "blocked: rm -rf / is not permitted"}\n'
else
printf '{}\n'
fi
3. 每個回合注入 git status(等同於 Claude Code 的 UserPromptSubmit)
hooks:
pre_llm_call:
- command: "~/.hermes/agent-hooks/inject-cwd-context.sh"
#!/usr/bin/env bash
# ~/.hermes/agent-hooks/inject-cwd-context.sh
cat - >/dev/null # discard stdin payload
if status=$(git status --porcelain 2>/dev/null) && [[ -n "$status" ]]; then
jq --null-input --arg s "$status" \
'{context: ("Uncommitted changes in cwd:\n" + $s)}'
else
printf '{}\n'
fi
Claude Code 的 UserPromptSubmit 事件刻意不作為獨立的 Hermes 事件 —— pre_llm_call 在相同的位置觸發,且已支援上下文注入。在這裡使用它。
4. 記錄每個子 agent 完成
hooks:
subagent_stop:
- command: "~/.hermes/agent-hooks/log-orchestration.sh"
#!/usr/bin/env bash
# ~/.hermes/agent-hooks/log-orchestration.sh
log=~/.hermes/logs/orchestration.log
jq -c '{ts: now, parent: .session_id, extra: .extra}' < /dev/stdin >> "$log"
printf '{}\n'
授權模型
每個唯一的 (event, command) 配對在 Hermes 第一次看到時會提示使用者核准,然後將決策持久化到 ~/.hermes/shell-hooks-allowlist.json。後續執行(CLI 或 Gateway)會跳過提示。
三個逃生出口可以繞過互動式提示 —— 任一即可:
- CLI 上的
--accept-hooks旗標(例如hermes --accept-hooks chat) HERMES_ACCEPT_HOOKS=1環境變數cli-config.yaml中的hooks_auto_accept: true
非 TTY 執行(Gateway、cron、CI)需要這三者之一 —— 否則任何新加入的 hook 會靜默地保持未註冊狀態並記錄警告。
腳本編輯會被靜默信任。 Allowlist 以精確的指令字串為鍵,而非腳本的雜湊值,所以編輯磁碟上的腳本不會使授權失效。hermes hooks doctor 會標記 mtime 漂移,讓你能夠發現編輯並決定是否要重新核准。
hermes hooks CLI
| 指令 | 功能 |
|---|---|
hermes hooks list | 列出已設定的 hook,包含 matcher、timeout 和授權狀態 |
hermes hooks test <event> [--for-tool X] [--payload-file F] | 對每個匹配的 hook 發送合成載荷,並列印解析後的回應 |
hermes hooks revoke <command> | 移除所有匹配 <command> 的 allowlist 條目(下次重啟後生效) |
hermes hooks doctor | 對每個已設定的 hook:檢查執行權限、allowlist 狀態、mtime 漂移、JSON 輸出有效性,以及大約的執行時間 |
安全性
Shell hook 以你的完整使用者憑證執行 —— 與 cron 條目或 shell 別名的信任邊界相同。將 config.yaml 中的 hooks: 區塊視為特權設定:
- 只參照你自己寫的或完全審閱過的腳本。
- 將腳本保持在
~/.hermes/agent-hooks/內,讓路徑容易審計。 - 拉取共享設定後重新執行
hermes hooks doctor,以在新 hook 註冊前發現它們。 - 如果你的 config.yaml 跨團隊進行版本控制,審閱修改
hooks:區塊的 PR,就像審閱 CI 設定一樣。
順序和優先順序
Python plugin hook 和 shell hook 都通過相同的 invoke_hook() 派發器。Python plugin 先註冊(discover_and_load()),shell hook 第二(register_from_config()),所以在平手的情況下,Python pre_tool_call 擋截決策有較高的優先順序。第一個有效的擋截會生效 —— 聚合器在任何 callback 產生帶有非空 message 的 {"action": "block", "message": str} 時就立即回傳。