Hermes AgentのPlugin DoctorをCIで使う|警告と終了コードを実機で確かめる

壊れたプラグインを本体へ入れてから気づくのは遅すぎます。Hermes Agent v0.21.0 には、登録処理を本番に近い経路で検査するhermes plugins doctorがあります。
2026 年 9 月 16 日、最小プラグインを作り、正常、manifest の宣言漏れ、register()の失敗を順番に試しました。結果は少し意外でした。登録失敗は--ciで終了コード 1 になりますが、manifest と登録内容の差は警告のまま終了コード 0 です。
私は Plugin Doctor を CI へ入れます。ただし、終了コードだけで全差分を防げるとは考えません。警告も記録し、レビュー対象に残す設計が必要です。
前回の設定移行検査から、拡張機能の入口へ進む
前回は「Codex CLIの設定をHermes Agentへ移す前に」で、import-agent --dry-runの書き込み境界を調べました。合成設定 4 件は適用されませんでしたが、起動処理により補助ファイルが作られました。
今回は設定移行ではなく、Python プラグインの登録経路を扱います。
| 時点 | 確認できる範囲 |
|---|---|
| 前回完了時 | 移行対象データと起動時の補助ファイルを分けて検査できます |
| 今回開始時 | プラグインのmanifest、import、登録処理が通るか未確認です |
| 今回完了後 | 正常、宣言差分、登録失敗を終了コードと出力で判定できます |
最小プラグインの作り方が分かる場合は「Plugin Doctor へ正常系を通す」まで飛ばせます。CI の判断だけ知りたい場合は「警告は終了コード 0 だった」から読めます。
なぜ起動前の検査が必要なのか
Hermes Agent のネイティブプラグインは、plugin.yamlと Python のregister(ctx)で構成します。manifest には提供する tool や hook を書き、登録処理では実際の schema と handler を runtime へ渡します。
この 2 か所は別々に編集されます。manifest へ tool 名を追加しても、register()への登録を忘れれば差が生まれます。逆に、import 時の例外やregister()の例外があれば、プラグインは読み込めません。
Plugin Doctor は単なる YAML lint ではありません。公式文書では、次の経路を実行すると説明されています。
plugin directory
│
├── plugin.yaml ── manifest parser ──────┐
│ │
├── __init__.py ─┐ │
├── plugin.py ───┼── namespaced import ──┼── register(ctx)
├── schemas.py ──┤ │ │
└── tools.py ────┘ │ ├── tool registry
│ └── hook registry
│
└── Doctor report + exit code
検査時には一時的なHERMES_HOMEを使い、登録状態を元へ戻します。直接の Python socket 接続も登録中は遮断されます。ただし sandbox ではありません。検査対象の Python code は現在の利用者権限で動き、subprocess も起動できます。信頼できないプラグインを安全に実行する機能ではありません。
用語を揃える
| 用語 | この回での意味 |
|---|---|
| manifest | plugin.yamlに書く名前、version、提供toolなどの宣言です |
| tool schema | modelがtoolの用途と引数を判断するJSON Schemaです |
| handler | toolが呼ばれたときに処理するPython関数です |
register(ctx) | schema、handler、hookなどをHermesへ登録する入口です |
| drift | manifestの宣言と実際の登録内容に差がある状態です |
--ci | 検査error時に非0で終了させるoptionです |
| 終了コード | shellやCIがcommandの成否を判断する整数です |
検証用の最小プラグインを作る
本番の~/.hermes/plugins/は触りません。/tmp/hermes-plugin-doctor-labを専用 directory として使います。同名の directory がある場合は上書きせず止めます。
LAB=/tmp/hermes-plugin-doctor-lab
[ ! -e "$LAB" ] || {
printf 'already exists: %s\n' "$LAB"
exit 1
}
mkdir -p "$LAB"
まず manifest を作ります。提供する tool はmorning_pingの 1 件だけです。
python3 - <<'PY'
from pathlib import Path
root = Path('/tmp/hermes-plugin-doctor-lab')
root.joinpath('plugin.yaml').write_text('''name: morning-check
version: 1.0.0
description: Minimal plugin for Plugin Doctor verification
provides_tools:
- morning_ping
''')
PY
model へ見せる schema には、引数なしの tool を定義します。
python3 - <<'PY'
from pathlib import Path
Path('/tmp/hermes-plugin-doctor-lab/schemas.py').write_text('''MORNING_PING = {
"name": "morning_ping",
"description": "Return a fixed readiness response.",
"parameters": {
"type": "object",
"properties": {},
"additionalProperties": False,
},
}
''')
PY
handler は固定の JSON を返します。外部通信も file 書き込みもありません。
python3 - <<'PY'
from pathlib import Path
Path('/tmp/hermes-plugin-doctor-lab/tools.py').write_text('''import json
def morning_ping(args, **kwargs):
return json.dumps({"ready": True})
''')
PY
plugin.pyで schema と handler を結びます。
python3 - <<'PY'
from pathlib import Path
Path('/tmp/hermes-plugin-doctor-lab/plugin.py').write_text('''from . import schemas, tools
def register(ctx):
ctx.register_tool(
name="morning_ping",
toolset="morning-check",
schema=schemas.MORNING_PING,
handler=tools.morning_ping,
)
''')
Path('/tmp/hermes-plugin-doctor-lab/__init__.py').write_text(
'from .plugin import register\n'
)
PY
__init__.pyを置く点が重要です。最初の試行では、この file を置かずに実行しました。Doctor はNo __init__.pyを返し、終了コード 1 になりました。directory があるだけでは Python package として登録経路へ進めません。
Plugin Doctorへ正常系を通す
検査 command は 1 行です。
hermes plugins doctor "$LAB" --ci
printf 'exit_code=%s\n' "$?"
Hermes Agent v0.21.0 で得た主要出力です。
Plugin Doctor: /tmp/hermes-plugin-doctor-lab
manifest: morning-check 1.0.0 (standalone)
OK: runtime discovery, manifest parsing, import, and registration passed
registrations: 1 tool(s), 0 hook(s)
exit_code=0
manifest を読み、Python package を import し、register(ctx)が 1 件の tool を登録しました。単に file の構文を確認しただけではありません。
ここで tool 自体の業務結果までは検査していません。morning_pingの handler が期待する JSON を返すかは、単体 test で別に確認します。Doctor が担当するのは、Hermes の runtime へ接続できるかという境界です。
manifestへ未登録toolを足す
次に、manifest だけへghost_toolを追加します。Python 側には登録しません。
python3 - <<'PY'
from pathlib import Path
path = Path('/tmp/hermes-plugin-doctor-lab/plugin.yaml')
text = path.read_text()
path.write_text(text + ' - ghost_tool\n')
PY
hermes plugins doctor "$LAB" --ci
printf 'exit_code=%s\n' "$?"
実出力では差を正しく見つけました。
Plugin Doctor: /tmp/hermes-plugin-doctor-lab
manifest: morning-check 1.0.0 (standalone)
WARN: manifest declares tool 'ghost_tool' but registration did not add it
OK: runtime discovery, manifest parsing, import, and registration passed
registrations: 1 tool(s), 0 hook(s)
exit_code=0
警告は終了コード0だった
ここが CI 設計の要点です。--ciを付けても、今回の drift は warning として扱われました。command の終了コードは 0 です。
これは Doctor が差を見逃したわけではありません。出力にはghost_toolが明示されています。ただし、shell のset -eや CI の通常の command step は止まりません。
私は次のように役割を分けます。
- import や登録の失敗は
--ciの終了コードで止めます - manifest drift の warning は job log と code review で確認します
- 提供 tool 一覧を厳密な契約にする project では、manifest と登録一覧を比べる専用 test も置きます
Doctor の出力文字列を安易にgrep WARNして失敗へ変える方法もあります。しかし、将来追加される無害な warning まで一括で止める可能性があります。warning の種類を理解し、project 側の契約として test を書く方が安定します。
register()の失敗はCIを止める
最後にregister()から合成した例外を投げます。これは障害動作の確認用です。本番プラグインへは入れません。
python3 - <<'PY'
from pathlib import Path
Path('/tmp/hermes-plugin-doctor-lab/plugin.py').write_text('''from . import schemas, tools
def register(ctx):
raise RuntimeError("synthetic registration failure")
''')
PY
hermes plugins doctor "$LAB" --ci
printf 'exit_code=%s\n' "$?"
実行結果は終了コード 1 です。
Plugin Doctor: /tmp/hermes-plugin-doctor-lab
ERROR: Plugin registration failed: synthetic registration failure
registrations: 0 tool(s), 0 hook(s)
exit_code=1
この状態なら、set -eを使う shell や GitHub Actions の step は失敗します。壊れた登録処理を有効化前に止められます。
CIへ入れる最小形
プラグイン repository の root が Doctor の対象なら、workflow の実行行は短くできます。
hermes plugins doctor . --ci
ただし、CI image に入っている Hermes Agent の version は固定します。plugin API の互換性は守られますが、検査項目や warning は追加され得ます。開発環境と CI で version が違うと、再現条件が曖昧になります。
私は次の順序で gate を組みます。
Python lint / type check
│
v
plugin unit tests
│
v
hermes plugins doctor . --ci
│
v
manifestと登録一覧の契約test
│
v
package build
Doctor だけへすべてを任せません。逆に、単体 test だけで Hermes 固有の登録経路を代用もしません。それぞれが違う故障を見つけます。
よくあるエラー
| 症状 | 主な原因 | 対応 |
|---|---|---|
No __init__.pyになります | plugin rootがPython packageになっていません | rootへ__init__.pyを置き、registerを公開します |
Plugin registration failedになります | importまたはregister(ctx)内で例外が出ました | tracebackと登録処理を確認し、合成環境で再実行します |
宣言したtoolへWARNが出ます | provides_toolsとctx.register_tool()が一致しません | tool名と条件分岐を照合します |
--ciなのにjobが成功します | 検出結果がerrorではなくwarningです | 終了コードだけでなくreportもレビューします |
| 手元では通りCIで失敗します | HermesやPythonのversion、依存packageが違います | CIのversionを固定し、同じlockを使います |
| Doctorを通せば安全だと思ってしまいます | Doctorをsandboxと誤認しています | 信頼できるcodeだけを実行し、必要ならcontainerも使います |
| handlerの返り値不具合を見逃します | Doctorはtoolの業務動作を網羅しません | handlerの単体testと統合testを追加します |
検証用directoryを片付ける
専用 directory と一致する場合だけ削除します。
[ "$LAB" = /tmp/hermes-plugin-doctor-lab ] || exit 1
rm -rf -- "$LAB"
~/.hermes/plugins/や実際の plugin repository は削除対象へ含めません。
終了コードだけでなく、検査の境界を読む
今回の最小プラグインは、正常時に 1 tool を登録し、終了コード 0 になりました。ghost_toolを manifest だけへ足すと drift を警告しましたが、終了コードは 0 のままでした。register()が例外を投げる状態では、終了コード 1 になりました。
この差を知れば、CI の緑を過信せずに済みます。Plugin Doctor は Hermes 固有の discovery、manifest、import、登録経路をまとめて確認する強い gate です。それでも warning の扱いと handler の動作確認は、project 側に残ります。
私は「Doctor が通ったから完成」ではなく、「どの故障を Doctor へ任せ、何を別 test で守るか」を先に決めます。AI エージェントへ機能を増やすほど、この境界の明文化が効きます。
一次情報
| 資料 | 確認した内容 |
|---|---|
| Hermes Agent: Build a Hermes Plugin | Plugin Doctorの実行経路、--ci、一時HERMES_HOME、socket遮断、sandboxではない点 |
| Hermes Agent CLI Commands | hermes pluginsのcommand体系 |
| NousResearch/hermes-agent | Hermes Agentの公式source repository |
| 2026年9月16日の合成プラグイン検証 | v0.21.0で正常、manifest drift、登録失敗の出力と終了コードを確認 |
