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

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 も起動できます。信頼できないプラグインを安全に実行する機能ではありません。

用語を揃える

用語この回での意味
manifestplugin.yamlに書く名前、version、提供toolなどの宣言です
tool schemamodelがtoolの用途と引数を判断するJSON Schemaです
handlertoolが呼ばれたときに処理するPython関数です
register(ctx)schema、handler、hookなどをHermesへ登録する入口です
driftmanifestの宣言と実際の登録内容に差がある状態です
--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_toolsctx.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 PluginPlugin Doctorの実行経路、--ci、一時HERMES_HOME、socket遮断、sandboxではない点
Hermes Agent CLI Commandshermes pluginsのcommand体系
NousResearch/hermes-agentHermes Agentの公式source repository
2026年9月16日の合成プラグイン検証v0.21.0で正常、manifest drift、登録失敗の出力と終了コードを確認
この記事をシェア