エージェント・ハーネス・パターン:Claude Codeのコアループから学ぶ教訓
教科書的なReActループを超えて
AIエージェントに関するチュートリアルは、どれも同じ図を示す:
Reason → Act → Observe → Repeat → Done
エレガントだ。スライドにも収まる。だが本番環境に触れた瞬間、壊滅する。
実世界のエージェントループは、ストリーミング割り込み、コンテキストウィンドウのオーバーフロー、APIレート制限、ツール実行タイムアウト、ユーザーキャンセル、モデルフォールバック、そして他にも十数種類の障害モードに対処しなければならない——すべてにわたって一貫した会話状態を維持しながら。教科書的なループと本番グレードのエージェントランタイムの間にあるギャップ、そこにハーネスエンジニアリングが存在する。
Claude Codeのオープンソース query.ts を一行ずつ読んだ結果、この特定のコードベースを超えて汎用化できるパターンを発見した。これをエージェント・ハーネス・パターンと呼ぶことにする。
核心的洞察:ループだけでは不十分
Claude Codeのエージェントループは、骨格として見れば標準的なReActループだ:
// query.ts:307 — 実際のループ
while (true) {
// Reason + Act: モデルを呼び出す
for await (const msg of callModel({...})) { // :659
if (msg has tool_use blocks) needsFollowUp = true
}
// Observe: ツール呼び出しなし → 完了
if (!needsFollowUp) return { reason: 'completed' } // :1357
// ツールを実行し、結果を追加して継続
for await (const update of runTools(...)) { ... } // :1384
state = { messages: [...old, ...assistant, ...toolResults] }
}
わずか5行の擬似コード。しかし実際のファイルは約1,700行ある。残りの1,695行はどこへ行くのか?
それらはハーネスに収まっている——剥き出しのループを包んで本番対応にするインフラだ。
エージェント・ハーネス・パターン
Claude Codeから繰り返し登場する構造を抽出すると、このパターンは5つのレイヤーで構成される:
レイヤー1:コンテキスト管理(APIコールの前)
各イテレーションでモデルにメッセージを送る前に、本番ハーネスはコンテキストを圧縮・削減・整形して制限内に収めなければならない。
Claude Codeは4つのコンパクターを順番にパイプライン実行する:
| コンパクター | 機能 | コード参照 |
|---|---|---|
applyToolResultBudget | メッセージごとのツール結果サイズを上限設定 | query.ts:379 |
snipCompactIfNeeded | 古い中間履歴を削除 | query.ts:403 |
microcompactMessages | 冗長なツール出力を圧縮 | query.ts:414 |
autoCompactIfNeeded | LLMによる完全な要約 | query.ts:454 |
教訓: コンテキストは「メッセージを追加するだけ」ではない。本番ハーネスには、APIコールのたびに実行されるコンパクションパイプラインが必要であり、トークン制限内に収まりながら最も関連性の高い情報を保持しなければならない。
レイヤー2:ストリーミング実行(APIコール中)
教科書的なループは完全なレスポンスを待ってからツールを実行する。Claude Codeはよりスマートなことをしている——モデルがまだストリーミング中にツールの実行を開始するのだ:
APIストリーム: ┃ テキスト ┃ tool_A ┃ テキスト ┃ tool_B ┃ 終了 ┃
│ │
addTool(A)│ addTool(B)│
▼ │ ▼ │
ツール: ┏━━━━━━━━━┿━━━━━┓ ┏━━━━━━━┿━━┓
┃ exec A │ ┃ ┃ exec B│ ┃
┗━━━━━━━━━┿━━━━━┛ ┗━━━━━━━┿━━┛
StreamingToolExecutor(StreamingToolExecutor.ts:40)は各ツールの状態マシン(queued → executing → completed → yielded)を管理し、partitionToolCalls(toolOrchestration.ts:91)によって並行安全なバッチとシリアルバッチに振り分ける。
教訓: 完全なレスポンスを待つな。モデル出力とツール実行を重ねるストリーミングエグゼキューターにより、ターンレイテンシを大幅に削減できる。安全性によってツールを振り分けよ——読み取り専用ツールは並列実行でき、書き込みツールはシリアル化が必要だ。
レイヤー3:リカバリーパス(APIコールの後)
ここでClaude Codeのハーネスが真価を発揮する。ループには7つの異なるリカバリーパスがある——それぞれが調整されたパラメーターでイテレーションを再試行する state = next; continue だ:
| リカバリー | トリガー | 戦略 |
|---|---|---|
| コラプスドレイン | プロンプト長すぎ(413) | ステージング済みコンテキスト圧縮をコミット |
| リアクティブコンパクト | 413が継続 | その場でLLM完全要約 |
| トークンエスカレーション | 出力が8kで切断 | 64k制限で再試行 |
| マルチターンリカバリー | 出力が依然切断 | 「直接再開」プロンプトを注入 |
| ストップフックリトライ | フックがブロッキングエラーを報告 | エラーを追加して再試行 |
| トークンバジェット継続 | バジェット未消費 | 継続を促すナッジを注入 |
| 通常の次ターン | ツール実行完了 | 結果を追加して継続 |
各リカバリーパスは transition タグ付きの新しい State オブジェクトを書き込む(例:{ reason: 'reactive_compact_retry' })。これにより無限ループを防ぐ——同じリカバリーが進捗なしで2回連続して発火した場合は、次の戦略にフォールスルーするか、終了する。
教訓: ただ try/catch して諦めるな。本番ハーネスにはリカバリーラダーが必要だ——最も安価なものから最も高価なものへと順序付けられた複数の戦略と、各ステップのサーキットブレーカーを備えること。
レイヤー4:終了条件(いつ停止するか)
教科書は「モデルがツールを呼び出さなくなったら停止」と言う。現実には10の異なる終了パスが必要だ:
completed — モデルが自然に完了(ツール呼び出しなし)
max_turns — ターン制限に達した
aborted_streaming — APIコール中にユーザーが中断
aborted_tools — ツール実行中にユーザーが中断
prompt_too_long — コンテキストオーバーフロー、全リカバリー消耗
model_error — APIが回復不能なエラーを返した
blocking_limit — ハードトークン上限に達した
image_error — メディア処理に失敗
stop_hook_prevented — 外部フックが継続を拒否
hook_stopped — フックがハードストップを通知
各終了は reason フィールドを持つ Terminal オブジェクトを返し、呼び出し元にループがなぜ終了したかの完全な可観測性を提供する。
教訓: 「完了」は一つの状態ではない。本番ハーネスには型付き終端状態が必要であり、呼び出し元がユーザーキャンセル・コンテキストオーバーフロー・自然完了を区別して適切に対応できるようにしなければならない。
レイヤー5:状態スレッディング(イテレーション間)
ループは可変の State オブジェクトをイテレーション間で持ち運ぶ:
// query.ts:204
type State = {
messages: Message[]
toolUseContext: ToolUseContext
autoCompactTracking: AutoCompactTrackingState | undefined
maxOutputTokensRecoveryCount: number
hasAttemptedReactiveCompact: boolean
turnCount: number
transition: Continue | undefined // なぜ継続したか
// ... その他のフィールド
}
すべての continue サイトで、次のイテレーションに必要な正確なフィールドを持つ新しい State が構築される。transition フィールドはリカバリー決定の監査証跡を作る。
教訓: 散在した可変変数を使うな。ループ状態を単一の型付きオブジェクトにまとめ、各継続サイトで再構築する。これによりループの動作が検査可能・テスト可能になる。
パターンの一般化
Claude Code固有の要素を取り除くと、エージェント・ハーネス・パターンはこのようになる:
┌─────────────── エージェント・ハーネス ───────────────┐
│ │
│ while (true) { │
│ ┌─ コンテキストパイプライン ──────────────────┐ │
│ │ 圧縮 → 削減 → 整形 │ │
│ └────────────────────────────────────────────┘ │
│ │ │
│ ┌─ モデル呼び出し(ストリーミング)─────────┐ │
│ │ イベントをyield │ │
│ │ ツールを並列開始 │ │
│ └────────────────────────────────────────────┘ │
│ │ │
│ ┌─ リカバリーラダー ──────────────────────────┐ │
│ │ 最安コストの修正を優先 │ │
│ │ 各ステップにサーキットブレーカー │ │
│ │ 失敗時はフォールスルー │ │
│ └────────────────────────────────────────────┘ │
│ │ │
│ ┌─ 終了チェック ──────────────────────────────┐ │
│ │ 型付き終了理由 │ │
│ │ 呼び出し元が原因を区別できる │ │
│ └────────────────────────────────────────────┘ │
│ │ │
│ ┌─ ツール実行 ────────────────────────────────┐ │
│ │ 振り分け:並行 vs シリアル │ │
│ │ 結果を収集 → state.messages │ │
│ └────────────────────────────────────────────┘ │
│ │ │
│ state = new State({...}) │
│ } │
└──────────────────────────────────────────────────────┘
なぜこれが重要か
コーディングアシスタント、データパイプラインオーケストレーター、自律型調査ツールなど——AIエージェントを構築するなら、最終的にClaude Codeが解決した同じ問題に直面するだろう:
- コンテキストが収まらない。 コンパクションパイプラインが必要だ。
- APIは失敗する。 リカバリーラダーが必要だ。
- ユーザーは中断する。 クリーンなアボート処理が必要だ。
- ツールは競合する。 並行性の振り分けが必要だ。
- 「完了」には多くの意味がある。 型付き終端状態が必要だ。
ReActループはカーネルだ。ハーネスこそがそれをプロダクトにするものだ。
まとめ
次にエージェントループを構築するとき、LLM呼び出しから始めるな。ハーネスから始めよ。自問せよ:コンテキストがオーバーフローしたらどうなるか?モデルが存在しないツール名を幻覚したら?ユーザーがツール実行中にCtrl+Cを押したら?APIが連続して3回413を返したら?
これらの質問に答えがないなら、ハーネスではなくデモを持っているにすぎない。