Hermes Agent Wiki 非公式・日本語wiki

Hermes のオブザーバーフック

目次

Hermes のオブザーバーフックは、実行時の挙動を変えずにエージェントの実行内容を 再構成したいプラグインのための、読み取り専用のテレメトリ契約です。この契約は、 Langfuse や OpenTelemetry 形式のコレクター、NeMo Relay といったトレース・メトリクス・ 監査・リプレイ・エクスポートの連携を想定しています。

オブザーバーフックは、意図的にどのバックエンドにも寄せていません。安定した ライフサイクルイベント、相関 ID、サニタイズ済みのペイロード、所要時間、状態、 エラーの各フィールドを公開します。Hermes のプランナー、モデルプロバイダー、メモリ、 ツールレジストリ、承認 UX、CLI、ゲートウェイの挙動、実行時の意味論を置き換えるもの ではありません。

挙動を変えるリクエストラッパーや実行ラッパーは、このオブザーバー契約の範囲外です。 オブザーバーフックは起きたことを報告するためのもので、プロバイダーへのリクエストや ツールの引数、実行コールバックを差し替えるためのものではありません。

Hermes には、NeMo Relay 向けの公式な共有メトリクス経路もあります。こちらは同じ ライフサイクルの区切りを直接使うので、可観測性プラグインを有効にする必要はありません。 Relay の共有メトリクスを参照してください。

契約

プラグインは register(ctx) からオブザーバーのコールバックを登録します。

def register(ctx):
    ctx.register_hook("pre_api_request", on_pre_api_request)
    ctx.register_hook("post_api_request", on_post_api_request)
    ctx.register_hook("pre_tool_call", on_pre_tool_call)
    ctx.register_hook("post_tool_call", on_post_tool_call)

どのフックのコールバックもキーワード引数で呼ばれます。フィールドが追加されても 互換性が保たれるように、プラグイン側は **kwargs を受け取るようにしてください。

def on_post_tool_call(**kwargs):
    tool_name = kwargs.get("tool_name")
    status = kwargs.get("status")
    result = kwargs.get("result")

プラグインマネージャーは、すべてのフックのペイロードに次のフィールドを差し込みます。

telemetry_schema_version = "hermes.observer.v1"

フックのコールバックは fail-open です。コールバックが例外を投げても Hermes が捕捉して 警告をログに出し、エージェントのループはそのまま動き続けます。

オブザーバーフックの戻り値は、ほとんどの場合は無視されます。例外は、挙動に影響する 古いフックです。

フック 戻り値の扱い
pre_llm_call 文字列または {"context": "..."} を返すと、現在のユーザーメッセージに一時的なコンテキストを差し込めます。
pre_tool_call {"action": "block", "message": "..."} を返すと実行前にツールを止められます。{"action": "modify", "args": {...}} を返すとツールの入力引数を書き換えられます。
transform_tool_result post_tool_call のあとに、ツールの結果を差し替える文字列を返せます。
transform_llm_output 最終的なアシスタント本文を差し替える文字列を返せます。

テレメトリ系のプラグインでは、こうした挙動に影響する戻り値は、可観測性のために必要な ものではなく、あくまで互換性のための任意機能として扱ってください。

相関 ID

オブザーバーのペイロードは安定した ID を持つので、プラグインはコールバックの順序だけに 頼らずにイベントを突き合わせられます。

フィールド 意味
session_id 会話・セッションの識別子です。
task_id タスクの識別子です。サブエージェントや分離された実行でとくに役立ちます。
turn_id ユーザーの 1 ターンの識別子で、そのターン内の API 試行とツール呼び出しが共有します。
api_request_id プロバイダーへの試行を表す不透明な識別子です。文字列の形式を解析しないでください。
api_call_count エージェントループ内での API 試行回数です。
tool_call_id プロバイダーが返したツール呼び出しの ID です(返ってきた場合)。
parent_session_id / child_session_id 委譲したサブエージェントとのセッションの結び付きです。
parent_subagent_id / child_subagent_id サブエージェント同士の結び付きです(取得できた場合)。
parent_turn_id 委譲した作業を生み出した親のターンです。

利用側では、複合的な ID を解析するのではなく、明示されたフィールドを使ってください。 とくに api_request_id は、中身を読まずに突き合わせるための値です。

イベントの系統

セッションのライフサイクル

セッション系のフックは、会話の区切りとリセットを表します。

フック 発火するタイミング
on_session_start システムプロンプトを組み立てたあと、新しいセッションが始まったとき。
on_session_end run_conversation の呼び出しが終わったとき。中断されたターンや未完了のターンも含みます。
on_session_finalize CLI やゲートウェイが、動作中のセッション識別子を破棄するとき。
on_session_reset CLI やゲートウェイが、古いセッション識別子から新しいものへ切り替えるとき。

よく使うフィールドには session_id、completed、interrupted、reason、 old_session_id、new_session_id があります(取得できる場合)。

on_session_end はターンや 1 回の実行の単位です。チャットの識別子にとっての最後の 区切りとは限りません。1 つのセッション識別子につき 1 回だけ行いたい後始末には、 on_session_finalize と on_session_reset を使ってください。

ターン単位の LLM フック

これらのフックは、プロバイダーへの個々の API 試行ではなく、ユーザーの 1 ターンを囲みます。

フック 発火するタイミング
pre_llm_call ユーザーのターンでツールのループが始まる前。
post_llm_call 最終的なアシスタントの出力が出てターンが完了したあと。

pre_llm_call でよく使うフィールドには session_id、turn_id、 user_message、conversation_history、is_first_turn、model、platform、 sender_id があります。

post_llm_call でよく使うフィールドには session_id、turn_id、 user_message、assistant_response、conversation_history、model、 platform があります。

LLM のスパンをテレメトリとして取るなら、リクエスト単位の API フックを使ってください。 pre_llm_call と post_llm_call は、ターン単位の文脈、互換性、ターンの最終的な まとめのために使います。

リクエスト単位の API フック

API 系のフックは、エージェントループ内でのプロバイダーへの試行を表します。

フック 発火するタイミング
pre_api_request プロバイダーへ API リクエストを送る直前。
post_api_request プロバイダーからの応答が成功したあと。
api_request_error プロバイダーへのリクエストが失敗したとき、または再試行の対象になるエラー経路を通ったあと。
pre_auxiliary_call 補助的な LLM 呼び出し(タイトル生成、圧縮、MoA、画像認識など)で、プロバイダーへ試行する前ごと。aux_task が付きます。*_api_request のフックはメインループ専用のままです。
post_auxiliary_call その試行が値を返したか例外を投げたあと(失敗時は error が入ります)。

pre_api_request に含まれるもの。

  • 識別子: session_id、task_id、turn_id、api_request_id
  • 実行環境: platform、model、provider、base_url、api_mode
  • 試行のメタデータ: api_call_count、message_count、tool_count、

approx_input_tokens、request_char_count、max_tokens

  • 時刻: started_at
  • サニタイズ済みのリクエストのペイロード: request

post_api_request には、同じ識別子と実行環境のフィールドに加えて次が含まれます。

  • api_duration、started_at、ended_at
  • finish_reason、message_count、response_model
  • usage
  • assistant_content_chars、assistant_tool_call_count
  • サニタイズ済みの応答のペイロード: response
  • 互換性のためのオブジェクト: assistant_message

api_request_error には、同じ識別子と実行環境のフィールドに加えて次が含まれます。

  • api_duration、started_at、ended_at
  • status_code、retry_count、max_retries、retryable、reason
  • 構造化されたエラー error = {"type": ..., "message": ...}
  • サニタイズ済みの、失敗したリクエストのペイロード: request

サニタイズ済みの request、response、error の各フィールドが、これから作る 利用側にとっての正式な入力です。

ツールのライフサイクル

ツール系のフックは、個々のツール呼び出しを表します。

フック 発火するタイミング
pre_tool_call ガードレールの承認を通ったツールを実行に回す前。
post_tool_call ツールの実行、キャンセル、ブロック、エラーのいずれかが終わったあと。
transform_tool_result post_tool_call のあと、結果がモデルのコンテキストに追加される前。

pre_tool_call には tool_name、args、task_id、session_id、 tool_call_id、turn_id、api_request_id が含まれます。

post_tool_call には、同じ識別子のフィールドに加えて result、 duration_ms、status、error_type、error_message が含まれます。

status は、オブザーバー向けに整えたライフサイクルの結果です。よく出る値は次のとおりです。

状態 意味
ok ツールが正常に終わりました。
error ツールは動きましたが、エラーを返したか例外を投げました。
blocked pre_tool_call のフックが実行を止めました。
cancelled 正常に終わる前に実行がキャンセルされました。

ブロックされた経路やキャンセルされた経路でも post_tool_call は発火するので、 テレメトリ系のプラグインはスパンをきれいに閉じられます。

人の入力のライフサイクル

on_human_input_request / on_human_input_resolved は、エージェントが人の応答を待って止まる すべての場面の前後で発火します。対象は、sudo のパスワード入力(kind="sudo")、 clarify の質問(kind="clarify")、承認の確認 (kind="approval")で、CLI、TUI/Desktop、ACP、ゲートウェイの各プラットフォームで発火します。フィールドは kind、request_id(対になる 2 つのフックで共通)、session_id、session_key、 platform、そして必ず伏せ字にされる prompt です。解決時のフックには outcome が加わります。 入力されたパスワードや回答そのものは決して含まれません。スマート(補助 LLM による)承認では 発火しません。実装は tools/human_input_hooks.py です。

承認のライフサイクル

承認系のフックは、危険なコマンドに対する承認の問い合わせを表します。

フック 発火するタイミング
pre_approval_request 承認の問い合わせを表示または送信する前。
post_approval_response 利用者が答えたあと、または問い合わせがタイムアウトしたあと。

よく使うフィールドには command、description、pattern_key、 pattern_keys、session_key、surface があります。

post_approval_response には choice も含まれ、値には once、 session、always、deny、timeout、cancelled などがあります(最後のものは誰も 答えなかった場合です。ターンが中断または終了して問い合わせが取り下げられたか、CLI で 利用者のところまで届かなかったケースです)。

承認系のフックは観測のためだけのものです。プラグインがこれらのフックから承認に 先回りして答えたり、拒否したりはできません。ツールが承認まで届かないようにしたい 場合は、pre_tool_call のブロックを使ってください。

サブエージェントのライフサイクル

サブエージェント系のフックは、委譲された子エージェントの作業を表します。

フック 発火するタイミング
subagent_start 委譲先の子エージェントが作られたとき。
subagent_stop 委譲先の子エージェントが結果を返したか、失敗したとき。

subagent_start のフィールドには parent_session_id、parent_turn_id、 parent_subagent_id、child_session_id、child_subagent_id、child_role、 child_goal があります。

subagent_stop のフィールドには、親子のセッション ID、役割と状態のフィールド、 child_summary、duration_ms、そしてメタデータだけの tool_call_history が あります。履歴の各項目には、ツール名、引数名、範囲を絞った副作用の対象、入出力の バイト数、結果が入ります。URL のクエリ文字列とフラグメントは取り除かれ、生の引数、 プロンプト、コマンド、内容、ヘッダー、結果は意図的に含めていません。

観測する側はこれらのフックを使って、入れ子になった軌跡を表現しつつ、子エージェントの 実行をそれを生み出した親のターンに結び付けたままにできます。

ペイロードの安全性

オブザーバーのペイロードは、生のオブジェクトを触るためではなく、テレメトリを受け取る側の ために設計されています。これから作る利用側では、サニタイズ済みの API ペイロードを 使ってください。

  • pre_api_request.request
  • post_api_request.response
  • api_request_error.request
  • api_request_error.error

サニタイズでは、プロバイダーのオブジェクトを JSON 互換の構造に変換し、大きな ペイロードに上限をかけ、機微なキーを伏せ字にし、生の応答オブジェクトが サニタイズ済みのフィールドに現れないようにします。

request_messages、conversation_history、assistant_message といった 従来互換のフィールドも、既存のプラグインのために残っている場合があります。 これから可観測性を組む側では、サニタイズ済みのペイロードを使ってください。

性能

計測を入れていない既定の経路は、軽いままであるべきです。コストのかかる リクエスト・応答のペイロード構築は has_hook(...) で囲まれているので、該当するフックを 登録したプラグインが 1 つ以上ある場合にだけ、Hermes はサニタイズ済みの API テレメトリの ペイロードを組み立てます。

プラグインを書く側も、この性質を保ってください。

  • 実際に使うフックだけを登録します。
  • サニタイズ済みのペイロードを深くコピーし直したり、もう一度サニタイズしたりしません。
  • フックのコールバックは速く、そして fail-open に保ちます。
  • ネットワークへの送出やまとめ書きは、可能なら別の場所へ逃がします。

オブザーバープラグインを書く

最小限のオブザーバープラグインです。

def register(ctx):
    ctx.register_hook("pre_api_request", on_pre_api_request)
    ctx.register_hook("post_api_request", on_post_api_request)
    ctx.register_hook("pre_tool_call", on_pre_tool_call)
    ctx.register_hook("post_tool_call", on_post_tool_call)

def on_pre_api_request(**kwargs):
    start_llm_span(
        request_id=kwargs.get("api_request_id"),
        turn_id=kwargs.get("turn_id"),
        request=kwargs.get("request"),
        model=kwargs.get("model"),
    )

def on_post_api_request(**kwargs):
    finish_llm_span(
        request_id=kwargs.get("api_request_id"),
        response=kwargs.get("response"),
        usage=kwargs.get("usage"),
        duration=kwargs.get("api_duration"),
    )

def on_pre_tool_call(**kwargs):
    start_tool_span(
        call_id=kwargs.get("tool_call_id"),
        name=kwargs.get("tool_name"),
        args=kwargs.get("args"),
    )

def on_post_tool_call(**kwargs):
    finish_tool_span(
        call_id=kwargs.get("tool_call_id"),
        result=kwargs.get("result"),
        status=kwargs.get("status"),
        duration_ms=kwargs.get("duration_ms"),
    )

スパンを突き合わせるには session_id、turn_id、api_request_id、tool_call_id を使ってください。エクスポート先の形式が入れ子のエージェント作業やセキュリティ関連の ライフサイクルイベントに対応しているなら、サブエージェント系と承認系のフックも使えます。

すでに使っている例

公式の Langfuse カタログプラグインは、ターン、プロバイダーへのリクエスト、 ツール呼び出しについて、フックを直接使った可観測性の実装例になっています。

NeMo Relay SDK との公式な統合は、Hermes のセッション、ターン、LLM、ツールの各 ライフサイクルを Relay へ対応付けます。Relay が見つけたユーザーとシステムの設定、または HERMES_NEMO_RELAY_PLUGINS_TOML で明示的に選んだファイルによって、 ATOF、ATIF、OTEL のエクスポーターや実行ミドルウェアを追加できます。 Relay の共有メトリクスを参照してください。