プロンプトは「提案」に過ぎない。フックは「強制的な停止」だ。
数ヶ月間、私はClaude Codeを、単に明確なルールを必要としているジュニアデベロッパーのように扱っていた。プロジェクトへの指示は明示的だった。決してforce-pushしない、決してブランチを削除しない、決して破壊的なコマンドを実行しない。ほとんどの晩は、それでうまくいっていた。エージェントはテストを書き、関数をリファクタリングし、gitの履歴には手を触れなかった。しかし、ある時リベースが失敗した。
コンテキストウィンドウがgitのエラー出力で埋め尽くされた。コンフリクトマーカー、detached HEADメッセージ、ブランチの分岐に関する警告が、トークンごとに積み重なっていった。そのノイズの下には、force-pushを避けるようにという私の丁寧な指示が埋もれていた。モデルにとって、スレッド内で最も新しく、かつ顕著なテキストはエラーのストリームだった。統計的なアテンション(注意)がポリシーに勝ってしまったのだ。エージェントは、2時間分の未コミットのローカル変更を消し去るコマンドを実行した。それは悪意によるものではなく、注意が逸れただけだった。この違いは重要だ。LLMは恨みからルールを破るのではない。コンテキストウィンドウ内のより強力なパターンが、以前の指示を一時的に上書きしてしまうからルールを破るのだ。
その出来事は、エージェントの安全性に対する私の考え方を変えた。99%の確率で機能するガードレールは、むしろリスクとなる。失敗のモードが時間、金銭、あるいは本番データを失わせるものであるなら、それをプロンプトの中に置いておくことはできない。モデルの推論ループの外側で強制力を働かせる必要がある。
Claude Codeのフックは、まさにこの問題を解決する。これらは、3つの特定のタイミングでツール呼び出しをインターセプトする小さなスクリプトである。ツールが実行される前(PreToolUse)、ツールが終了した後(PostToolUse)、そしてエージェントが完了を決定した時(Stop)だ。これらは外部コードとして実行されるため、モデルのメモリ、気分、あるいはコンテキストの圧力に依存しない。モデルがこれまで与えられたすべての指示を忘れたとしても、フックは依然として「ノー」と言い続ける。
以下は、あの失われた晩の後に私が構築した仕組みだ。
The Guard Hook: Intercept Before Damage(ガード・フック:被害が出る前に遮断する)
私のPreToolUseフックは、シェルがコマンドに触れる前に、すべてのBashコマンドを検査する。私は破壊的なパターンの厳選された拒否リスト(denylist)を保持している。コマンド文字列が危険なものに一致した場合、フックは実行を中止し、エラーを直接エージェントに返す。
ブロックするパターンは単純で曖昧さがない:
git push --forceまたは、まだ信頼していないあらゆるforce-with-leaseのバリエーションgit reset --hardrm -rf
これは高度なセキュリティ研究ではない。シートベルトなのだ。しかし、重要な詳細はブロックの後に何が起こるかにある。
私は決して、単に「Blocked」と突き放すようなことはしない。無愛想な拒否はエージェントを混乱させ、同じ破壊的なコマンドのバリエーションを試そうとするループに陥らせる可能性がある。代わりに、エラーメッセージには回避策を含める。フックがハードリセットを検知したとき、エージェントにこう伝えるのだ。「このコマンドは未コミットの作業を保護するためにブロックされました。まずチェックポイントをコミットしてから、再度検討してください。」 この一文があるだけで、エージェントの挙動は劇的に変わる。ダメージコントロールを試みるのではなく、安全性を確保する方向へと転換するのだ。フックは単なる壁ではなく、交通整理なのだ。
また、シェルコマンドについては許可リスト(allowlist)ではなく拒否リスト(denylist)を採用した。当初は、安全なgitサブコマンドの明示的なセットのみを許可することを検討した。しかし、それはすぐに失敗した。エージェントは、指示を文字通りに、かつ独創的に解釈してしまう。彼らは、状態を確認するために git stash push -m "wip" や git branch --show-current といった、正当だが予期しないコマンドを実行する。許可リストは、モデルが有効だがリストにないコマンドを考案した瞬間に、通常のワークフローを壊してしまう。真に破壊的なパターンを厳選した短い拒否リストであれば、境界線を守りつつ、エージェントに自由な動きを許容できる。
The Formatter Hook: Automate the Busywork(フォーマッタ・フック:単純作業の自動化)
以前は、エージェントに対して「ファイルを編集した後は常にフォーマッタを実行して」と指示するために、プロンプトのトークンを浪費していた。しかし、エージェントは半分くらいの確率でそれを忘れた。残りの半分では、フォーマットすべきかどうかを停止して尋ねてくるため、唯一の正解しかない決定に対してツール呼び出しを無駄に消費していた。
今では、それをPostToolUseフックで処理している。エージェントがファイルを編集した後、フックがファイルの拡張子をチェックする。PythonであればRuffを実行し、JavaScriptまたはTypeScriptであればPrettierを実行する。Goであれば gofmt を実行する。エージェントはフォーマッタが存在することすら知らない。知る必要もない。
これをプロンプトから切り離したことで、2つの効果が得られた。第一に、モデルに認知負荷をかけることなく、コードを一貫してクリーンに保てるようになった。第二に、プロジェクトの指示が短くなった。プロンプトから「常に」や「決して〜しない」を一つ取り除くたびに、モデルが実際の問題解決に使えるトークンが増える。フックは不変条件(invariant)を担い、プロンプトは意図(intent)を担う。
The Quality Gate: Redefining “Done”(クオリティ・ゲート:「完了」の再定義)
Stopフックは、エージェントがタスクを完了したと判断し、セッションを終了しようとしたときに実行されます。私はそれを許しません。代わりに、フックがテストスイート全体を実行します。もしテストが一つでも失敗すれば、フックは停止コマンドをブロックし、失敗の出力をエージェントに返します。
これにより、「完了」の定義が変わります。「完了した」というのは、もはやモデルが抱く感覚ではありません。それは測定可能なゲート(関門)なのです。エージェントは、ハーネスがコードの動作を確認したときにのみ終了できます。実際、これにより緊密なフィードバックループが生まれます。エージェントはコードを書き、完了したと思い、停止ボタンを押し、即座にpytestのトレースバックを目にします。そして、インポートエラーやアサーションの失敗を自己修正して直し、再び停止を試みます。私は、エージェントが人間の介入なしに、このループの中で3、4回と反復を繰り返す様子を見てきました。ハーネスが品質を強制し、モデルがパッチを提供します。
これがエージェント・エンジニアリングについて教えてくれること
信頼性の高い自律型システムを構築するには、マインドセットの転換が必要です。より長いプロンプトを書くことから、より強固なハーネスを構築することへと移行するのです。
強制にはフックを、ポリシーにはプロンプトを使用してください。もしルールが100%守られなければならないものであれば、それは自然言語ではなくコードに記述すべきです。プロンプトは曖昧さ、センス、アーキテクチャには優れていますが、不変条件(invariants)には全く向いていません。もしミスによって午後の復旧作業を強いられたり、最悪の場合、本番環境の稼働時間を損なったりするようなことがあれば、フックを書きましょう。
プロンプトは短いほうが良い結果を生みます。機械的なルールをスクリプトに移行すれば、モデルが記憶すべきことや矛盾すべきことが減ります。エージェントのコンテキストウィンドウは貴重なリソースです。フォーマットに関する指示で埋め尽くしてはいけません。
最後に、自身の役割が変化していることを受け入れてください。エージェントが自律性を獲得するにつれ、人間の仕事はコンテンツの生成からガードレールの設計へとシフトします。あなたは、モデルが何に触れられるか、いつ終了できるか、そして問題が発生したときにどのように振る舞うべきかを決定するハーネスを構築しているのです。それはプロンプティングではなく、エンジニアリングです。
このアプローチの着想源と詳細な実装については、こちらをご覧ください。
AIエージェントを使って開発しており、他の実践者と知見を共有したい場合は、GyaanSetuの学習コミュニティをこちらから見つけることができます。
