コンテンツにスキップ

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

フックは、reyn セッションの 4 つのライフサイクルポイントで、コンテキストの注入・自己継続トリガー・サンドボックス内副作用実行・pipeline 起動を行う、薄いオペレータースコープレイヤーです — あるいは、セッション自身のランループの外側で発火する外部イベントポイントでも(購読中の MCP resource の変更、監視ファイルの変更、cron ジョブの発火、または受信 webhook)。

フックは既存の 2 つの仕組みの上に構築されています: 統合インボックス(ターンにメッセージを供給するチャネル)と P6 ライフサイクル(イベントストリーム)。新しい OS 機構は追加しません。フックを使うワークフローは OS の変更を必要としません(P7)。

ライフサイクルポイント

フックはスコープと方向の組み合わせで 4 つのライフサイクルポイントに発火します:

スコープ _start _end
session session_start session_end
turn turn_start turn_end

各ポイントはawaited ディスパッチです。シェルの完了・プッシュのキューへの登録が終わってから、ライフサイクルポイントが次へ進みます。これにより、シェルフックはそのタイミングに同期的にアクセスできます(例: session_start シェルフックは最初のターンが始まる前に完了します)。

実装上のアンカー:

  • turn_end は terminal stop_reason で発火

外部イベントポイント

上記の 4 つのライフサイクルポイントとは異なり(セッション自身のターンランループから発火)、外部イベントポイントはそのループの外側で発火します: 現状、購読中の MCP resource の変更です。

mcp_resource_updated

このセッションが subscribe_mcp_resource で購読した resource に対して、サーバが resources/updated 通知をプッシュしたときに発火します(Resource subscriptions 参照)。MCP receive-loop タスクから、境界付きキューを介してセッション自身のイベントループ上でドレインされます — エージェント自身のターンの仕組みからではないため、ターン境界だけでなくターンの間でも発火し得ます。

template_push / pipeline_launch のレンダリングで使えるテンプレート変数:

変数 意味
server resource が属する MCP サーバー名。
uri 更新された resource の URI。
resync この発火が reconnect resync の場合 true、実際のサーバープッシュの場合 false

Reconnect 時の resync。 reyn は resource content のキャッシュを一切保持しないため、切断中の更新を見逃す可能性があります。トランスポート断からの reconnect が以前追跡していたすべての購読を再確立した後、以前購読していた各 URI に対してこのフックポイントが resync: true で一度ずつ発火します — 「切断中に変わったかもしれない、気にするなら読み直せ」という控えめなシグナルで、実際のプッシュと全く同じフックポイント・テンプレート変数の形を使います。セッションの最初の接続では決して発火しません(resync するものが無いため)。

file_changed

operator が宣言した監視パス配下のファイルが作成・変更・削除されたときに発火します。watchdog extra(pip install reyn[fs-watch])と、reyn.yamlfs_watch.paths に少なくとも 1 つのパスが必要です — reyn-yaml § fs_watch ブロック 参照。どちらか一方でも欠けていると、この機能はオフです(パスは設定されているが extra が無い場合は一度だけ警告がログされ、設定が全く無い場合はウォッチャーの無いビルドとバイト同一のまま静かに何もしません)。

テンプレート変数:

変数 意味
path 変更されたファイルのパス。
event_type createdmodifieddeleted のいずれか。

監視パスは起動時に一度だけ、OUT-set(reyn.yaml / reyn.local.yaml)で宣言されます — エージェントが監視を登録したり広げたりできる op やツール verb はありません。ファイルシステム全体の変更フィードは、サンドボックスポリシーと同じクラスの懸念事項として扱われます。1 つの論理的な変更に対するイベントのバースト(エディタの一時ファイルの動き、create-then-modify)はパスごとにデバウンスされます — 1 つのバーストはフックを 1 回だけ発火させ、基盤となるファイルシステムイベントごとに発火することはありません。

cron_fired

message ベースの cron: ジョブが自身のセッションに配送されるときに発火します。

テンプレート変数:

変数 意味
job_name 発火したジョブの設定名。
to ターゲットのエージェント名。

webhook_received

受信 webhook(Slack、LINE、汎用プラグイン)がセッションに解決されたときに発火します。

テンプレート変数:

変数 意味
transport 論理トランスポート(slacklinewebhook など)。
sender 完全なルーティング sender 文字列("<transport>:<external_id>")。

テンプレートコンテキストは意図的にこのルーティングメタデータのみを運びます — 受信リクエストの生ボディは決して含みません。それは operator が hook アクションに見せるつもりのなかったトークンや PII を運んでいる可能性があるためです。対照的に cron_firedjob_name/to は operator が記述した設定であり、エンドユーザー由来ではありません。

cron_firedwebhook_received はいずれも、自身の ingress に対して非ブロッキングです: cron ジョブ自身のインボックス配送、および webhook の HTTP レスポンスは、いずれも hook アクションを待ちません — ディスパッチは fire-and-forget のバックグラウンドタスクとしてスケジュールされるため、遅い hook(例えば数秒かかる exec)がそれをトリガーした ingress をストールさせることはありません。

Matcher: どのイベントがフックを発火させるかを絞り込む

フックは matcherdict[str, str] のフィールド → パターン)を設定でき、フックのアクションが実行されるに、発火したイベントのテンプレート変数に対して評価されます:

hooks:
  - "on": mcp_resource_updated
    matcher: {server: "github", uri: "file:///repo/**"}
    template_push:
      message: "{{ uri }} changed on {{ server }}."
  • matcher に列挙されたすべてのフィールドがマッチする必要があります: uripath を除き厳密な文字列一致uri/path はシェル風の glob(fnmatch)でマッチします — そのため file:///repo/** はそのプレフィックス以下のあらゆる URI に、/repo/src/** はその配下のあらゆる監視パスにマッチします。
  • 10 個のbuiltin フックポイント(6 lifecycle + mcp_resource_updated/file_changed/cron_fired/webhook_received)では、matcher のフィールドはそのポイントの builtin schema が実際に持つものでなければなりません — typo や存在しないフィールド名(例: ライフサイクルポイントの matcher に server/uri を指定、または payload.srever)はload 時の HookConfigError となり、フックが一度も実行されないまま拒否されます(schema-external な matcher は本来「決して発火しない」サイレントな罠になるところを、fail-loud で置き換えています)。
  • builtin schema を持たない将来/custom ポイント(schema 駆動の open set)では、発火イベントに含まれないフィールドは引き続き実行時に決してマッチしません — load 時に検証対象となる schema が無いため、旧来の挙動のままです。
  • matcher が無い、または空 → フックは常に発火します — デフォルトであり、matcher 以前のすべてのフックの挙動を変えません。

このルールはフックポイントではなくフィールドにキーされています(uri/path は glob、それ以外は厳密一致)— そのため、将来の外部イベントソースが uripath 形式のフィールドを発するようになれば、無料で glob マッチングが得られます。

4 つの設定スキーム

各エントリは相互排他な 4 つのスキームのちょうど 1 つを持ちます:

  • template_push — 設定の Jinja2 テンプレートから組み立てるプッシュ指示。
  • exec — 純粋な副作用として実行するサンドボックス argv(出力は無視)。#3226 Phase 4 で shell_exec からリネーム(命名の誠実化のみ — セキュリティ上の変更ではない。この仕組みは元々 /bin/sh -c <文字列> を一度も実行しておらず、常に shell=False で argv を実行していた)。payload は argv リストのみ(クリーンブレイク — 文字列形式の後方互換は無い)。
  • exec_capturestdout が JSON プッシュ指示であるサンドボックス argv。template_push と同じ経路でプッシュされます(違いは指示のソースのみ: キャプチャした stdout か Jinja2 レンダーか)。#3226 Phase 4 で shell_push からリネーム、同じく argv リストのみの payload。
  • pipeline_launch — 発火イベントのテンプレート変数からレンダリングした input で、登録済みの pipeline を起動します。詳細は下記の Pipeline launch を参照。

4 つのケイパビリティ

これらのスキームは均一に 4 つの振る舞いケイパビリティを提供します:

C — コンテキスト注入(wake: false のプッシュ)

[hook:name] 属性付きのシステムメッセージが統合インボックスにキューされます。次のターンで一緒に届きます — 追加ターンは発生しません。LLM がすぐに行動しなくてよい読み取り専用コンテキスト(メトリクス・タイムスタンプ・取得済みファクト)を付加するのに使います。template_push または exec_capture の指示が wake: false のときに生成されます。

E — 自己継続(wake: true のプッシュ)

C と同じですが、wake: true フラグがランループに新しいターンを即座に開くよう指示します。これがフックの差別化ケイパビリティです: turn_end フックは人間の入力なしにエージェントを再起動できます。ループバルブ で制限されます。template_push または exec_capturewake: true のときに生成されます。

F — 外部副作用(exec

サンドボックス内でコマンドを実行します。reyn は JSON イベントをコマンドの stdin に書き込み、stdout と stderr は無視します。外部状態の更新(ログエントリの書き込み・メトリクスの発信・Webhook へのポスト)に使います。安全モデルは サンドボックス を参照してください。

計算されたプッシュ(exec_capture

stdout が単一の JSON オブジェクト {"push_when": bool, "wake": bool, "message": str, "session"?: str}(最初の 3 つは必須)であるサンドボックスコマンドです。stdout は template_push が生成するのと同じプッシュ指示にパースされ、同一の C/E 経路でディスパッチされます — つまりコマンドがランタイムにプッシュするか(push_when)・どう(wake)・何を(message)を決定します。stdout は純粋な JSON である必要があります(ログは stderr へ)。いかなる失敗(非ゼロ終了・無効な JSON・必須/型不一致フィールド)もプッシュをスキップします(フェイルセーフ)。ライフサイクルポイントは常に続行されます。sessionクロスセッションプッシュ(下記参照)の宛先セッションを指定します — 省略時は現在のセッションがデフォルトです。

Pipeline 起動(pipeline_launch)

発火したイベントのテンプレート変数から組み立てた input で、登録済みの pipeline を名前で起動します:

hooks:
  - "on": mcp_resource_updated
    matcher: {uri: "file:///repo/docs/**"}
    pipeline_launch:
      name: reindex_docs
      input_template: {uri: "{{ uri }}"}
  • name — pipeline の登録名。ディスパッチ時に解決されます。登録されていない場合、フックは警告をログして起動をスキップします — ライフサイクル/外部イベントポイントは他のフック失敗と全く同様に、正常に完了します。
  • input_template — 任意。dict の場合、その文字列リーフ(再帰的に)がそれぞれテンプレート変数に対して Jinja2 レンダリングされます。プレーンな文字列の場合、一度レンダリングされ、その出力が JSON オブジェクトとしてパースされます(exec_capture の「stdout は JSON」契約を反映)。省略時は、pipeline は input なしで起動します。
  • 非同期 / detached で、どのフックポイント(ライフサイクルでも mcp_resource_updated でも)からも動作します: 起動は run_pipeline(name=..., collect="async") と同じ経路です — フックは fire-and-continue し、pipeline は自身の crash-recoverable な driver-session で実行され、結果は後でこのセッション自身のインボックスに pipeline_result メッセージとして届きます。

クロスセッションプッシュ

template_push または exec_capture 指示の session フィールドは、プッシュを現在のセッションではなく別のセッションのインボックスへルーティングします — ターゲットセッションは、自身のフックプッシュと全く同様にそれを処理します(wake は一緒に運ばれます: true はターゲットでターンをトリガーし、false はターゲットの次のターンにパッシブに乗ります)。現在のセッションを指定した場合、session を完全に省略した場合、クロスセッションルーティング能力のないコンテキストで実行している場合は、いずれもローカル(現在のセッション)プッシュにフォールバックします。

wake フラグとランループ

wake(デフォルト true)が C と E を分けます。ランループは各ターン後にインボックスをドレインします:

  1. キューされた全フックメッセージを収集します。
  2. wake: false のメッセージは次のターンのコンテキストとして含められます(wake: true がなければ次の人間駆動ターンまで保留)。
  3. wake: true が 1 つでも存在すれば、ループは1 ターンを発火します — 同じバッチの wake: false メッセージはそのターンのコンテキストとして一緒に届きます。

フックが設定されていないか、現在のライフサイクルポイントに一致しない場合、ループはフックなしのセッションとバイト同一です。ハッピーパスでオーバーヘッドはゼロです。

忠実性

プッシュは会話に追加される新規の [hook:name] 属性付きシステムメッセージです。既存の履歴を変更しません — オブジェクト同一性レベルで検証済みです(内容の等価比較ではなく)。

シェル出力は意図的に無視されます。reyn はトランスフォームフック(コンテキストやアーティファクトストリームを書き換えるフック)をサポートしません。実際のリダクション・トランケーション・コンテンツフェンスは OS レイヤーで実装されており、可視・イベント記録・監査可能です(secret-handling および コンテンツレイヤー防御 を参照)。

awaited ディスパッチアーキテクチャ

フックは HookDispatcher によってディスパッチされます。これは各ライフサイクルポイントでの第一級の同期 awaited 呼び出しです。EventLog サブスクライバーとは異なります:

仕組み タイミング 用途
HookDispatcher awaited 第一級 フック — ライフサイクルポイントが次へ進む前に完了必須
EventLog サブスクライバー sync-inline、await なし リアルタイムコンソールレンダー、アナリティクス
WAL 追記専用の永続ログ クラッシュリカバリ
P6 監査イベント async-tolerant 監査トレイル、リプレイ、eval

サブスクライバーは sync-inline で await できません — emit 時点でのファイア&フォーゲットです。プロセス終了を待つ必要があるシェルフックはサブスクライバーとして実装できません。HookDispatcher がこれを解決します。

各フックは独自の try/except ブロックでラップされます。フックの失敗はフック名に属して記録されますが、ライフサイクルポイントを中断したり LLM 出力に伝播したりしません。

ループバルブ

E(自己継続)は暴走するフック駆動セッションを防ぐために制限されています:

  • カウンター: safety.loop.max_hook_driven_turns(デフォルト 25)は最後の人間ターン以降のフック駆動ターン数をカウントします。
  • リセット: カウンターは人間ターンのたびにゼロにリセットされます。
  • 上限到達時: 設定された safety.on_limit アクションが発火します — warnask_userabort。いずれもセッションを生かし続けます(サイレントキルなし)。
  • 制限なし: max_hook_driven_turns: 0 に設定すると上限を無効化できます。

バルブは障壁ではなく安全網です。適切に設計された自己継続フックはキャップに達する前に完了します。バルブはバグや予期しないワークフローの動作によって開きっぱなしになるループを捕捉します。

サンドボックス

exec/exec_capture フックは、Control IR の sandboxed_exec op と同じバックエンド非依存のサンドボックス抽象化の中で実行されます: Seatbelt(macOS)、Landlock/seccomp(Linux)、Noop(非対応プラットフォーム)、またはコンテナバックエンド。

シェルフックのサンドボックスはフック単位でスコープされます。3 つの軸が 床(floor) から始まり、 そのフック自身のキーだけがそれを動かせます (hooks: ブロック):

オペレーターのキー
fork サブプロセス生成なし subprocess: true
network アウトバウンドネットワークをブロック network: true
書き込み 書き込み可能パスなし write_paths: [...]

キーを省略すればその軸は床のまま維持されるため、何も宣言しないフックは従来どおり束縛されます。 各軸が(グローバルではなく)フック単位なのは意図的です: フックがサンドボックスに何を必要とするかは、 オペレーター自身のコマンドの性質だからです。git/npm フックは fork しますが、純 python のものは しません — ∴ 安全な一律デフォルトは存在しません(対照的に stdio MCP サーバーの subprocess: が既定 on なのは、その種のサーバーが fork しなければ起動できない ため)。内部で fork するコマンド — pyenv/asdf/mise の shim に解決される裸のコマンドや npx/uvx ランチャー — は床の下では拒否され、 denial_class=fork_denied としてログされます(コマンドの失敗ではなく環境/設定の問題であることを明示)。

キーに関わらず保たれる境界が 2 つあります:

  • 機微ファイルの読み取り拒否リスト(~/.ssh~/.aws など)は、他のサンドボックス実行と同様に シェルフックにも適用され、write_paths の許可はそれを貫けません — 重なる場合は deny が勝ちます。
  • コンセント失敗クローズ: サンドボックスバックエンドが確認できない場合、サンドボックスなしで実行するのではなくシェルフックを拒否します。

なぜエージェントレベルの sandbox.policy はフックのポリシーではないのか

エージェントレベルの sandbox.policy は、 サンドボックス化された op と OS のインプロセス file/http ゲートを統べますが、シェルフックには 到達しません。これは見落としではなく構造的な選択です: フックはライフサイクルイベントへの 小さな宣言的リアクションであり、その床が「その run の op が意図的に非サンドボックスだから」という 理由で動くべきではありません。フックのサンドボックスが決まる場所はフックのサイトです。

ただし OS はあなたを黙って無視しません。エージェントレベルのポリシーが上記 3 軸のいずれかを宣言し、 シェルフックがそれを再宣言していない場合、その軸に到達する per-hook キーを名指す WARNING をログし、 sandbox_policy_not_applied audit-event(何が設定され・実際に何が適用され・どのフックか)を発行します。 フックにそのキーを — 床の値を含む 任意の 値で — 宣言することがその軸に対するあなたの決定であり、 決定は報告されません。不変条件: オペレーターの表明された意思は、適用されるか拒否されるかであり、 黙って捨てられることはない。

コンセントと許可リスト

シェルフックコマンドを実行する前にオペレーターのコンセントが必要です。コンセントフローはライブの介入リスナーが接続されているかによって変わります:

  • 対話的チャットセッション(インライン CUI) — コンセントは統合介入バスを通じてルーティングされ、入力欄上部のリージョンに選択式介入として表示されます: "Shell hook <name> wants to run a command"(フックに設定された name: フィールド、または未設定の場合は汎用メッセージ)。3 つの選択肢:
  • [A]lways — 許可し許可リストに永続化します(~/.reyn/shell-hooks-allowlist.jsonREYN_SHELL_HOOKS_ALLOWLIST 環境変数で上書き可)。同じコマンドの将来の実行は自動承認されます。
  • [y]es — 今回の実行のみ許可します。
  • [n]o — スキップ(失敗クローズ)。
  • 非対話的(reyn runmcp-serve、ヘッドレス) — バス使用前の動作にフォールバック: TTY stdin が利用可能なら stdin プロンプト、TTY でない場合は拒否。
  • 許可リストヒット — 許可リストに既存のコマンドはすべてのサーフェスでプロンプトなしにサイレント自動承認で実行されます。

コンセントは全体を通じて失敗クローズです: サンドボックスバックエンドが確認できない場合、サンドボックスなしで実行するのではなくフックを拒否します。

フルバックエンドモデルは sandbox、より広いコンセントアーキテクチャは permission model を参照してください。

P6 イベント: hook_shell_executed

すべての exec/exec_capture フック実行 — サイレント自動承認の実行も含む — は hook_shell_executed P6 イベント("tool" グループ。イベント種別名自体は #3226 Phase 4 で変わらず、下記の mode 値のみリネームされた)を発行し、次を記録します:

exec: <コマンド> [rc=N]

(プッシュモードのフックは exec_capture: プレフィックスになります — #3226 Phase 4 で shell_exec:/shell_push: からリネーム。)コマンドが exit 0 の場合、リターンコードのサフィックスは省略されます。これにより、コンセントパスに関わらず exec フックアクティビティの完全な監査トレイルをオペレーターに提供します。

設定

フックは reyn.yamlhooks: キーの下で宣言します。フルスキーマは reyn-yaml リファレンス § hooks ブロック を参照してください。

簡単な例 — turn_end 自己継続 template_pushsession_start exec、stdout がプッシュを決める turn_end exec_capture、matcher で絞り込んだ mcp_resource_updatedpipeline_launch:

hooks:
  - "on": turn_end
    template_push:
      message: "Run complete. Check for pending tasks."
      wake: true

  - "on": session_start
    exec: ["touch", "/tmp/reyn-session-started"]   # argv のみ — シェルリダイレクト(">>")は行われない

  - name: dynamic
    "on": turn_end
    exec_capture: ["scripts/decide-next.sh"]   # {"push_when":true,"wake":true,"message":"..."} を出力

  - "on": mcp_resource_updated
    matcher: {server: "github", uri: "file:///repo/docs/**"}
    pipeline_launch:
      name: reindex_docs
      input_template: {uri: "{{ uri }}"}

最初のフックの wake: true は各 turn_end の後に新しいターンをトリガーし、メッセージをシステムコンテキストとして注入します。session_startexec は純粋な副作用として argv を実行します(出力は破棄。argv は直接実行されるためシェルは介さず、>> のようなリダイレクトトークンはリダイレクトとして機能しません — シェルの意味論が必要な場合はスクリプトか明示的な ["sh", "-c", "..."] argv を使ってください)。exec_capture はその argv を実行し stdout をパースし、指示がそう言うときだけプッシュします。最後のフックは github サーバーの docs/ 配下の resource に対してのみ発火し — 発火した際は、変更された URI を input として reindex_docs pipeline を非同期に起動します。

未実装(Deferred)

以下のケイパビリティは設計済みですが未実装です:

  • エージェントレベル・フェーズレベルフック — ターン内の細粒度ポイント(稀なユースケース。session/turn が一般的なケースをカバー)。

参照