エージェントループの内側:Gemini CLI がプロンプトを処理する仕組み
前提知識
- ›第1回:アーキテクチャとナビゲーションガイド
- ›TypeScript の async generator とイテレーター
- ›LLM のツール呼び出しとストリーミングの基礎知識
エージェントループの内側:Gemini CLI がプロンプトを処理する仕組み
Gemini CLI にプロンプトを入力すると、そこから数十ターンに及ぶループが始まります。モデルへのリクエスト送信、ストリーミングレスポンスの受信、ツール呼び出しのディスパッチ、結果のフィードバック。このサイクルを、タスクが完了するかモデルが終了を通知するまで繰り返します。単純なリクエスト/レスポンスのサイクルではなく、ストリーミングイベント、リトライロジック、圧縮、ループ検出、フック統合を各段階に備えた3層アーキテクチャです。
この記事では、ユーザープロンプトが GeminiClient、Turn、GeminiChat を経由する完全なライフサイクルを追っていきます。
GeminiClient:外側ループのオーケストレーター
GeminiClient クラスは、LLM とのすべてのやり取りのエントリーポイントです。AgentLoopContext(第1回で解説)を受け取り、ループ検出、チャット圧縮、ツール出力のマスキング、フックの発火など、会話のライフサイクル全体を管理します。
中心となるメソッドは line 881 の sendMessageStream() です。AsyncGenerator<ServerGeminiStreamEvent, Turn> として定義されており、内部で複数のモデルターンを管理しながら、呼び出し元にストリーミングイベントを yield します。
sequenceDiagram
participant UI as UI Layer
participant GC as GeminiClient
participant T as Turn
participant Chat as GeminiChat
participant API as Gemini API
UI->>GC: sendMessageStream(request, signal, prompt_id)
GC->>GC: fireBeforeAgentHook
GC->>GC: processTurn()
GC->>T: new Turn(chat, prompt_id)
T->>Chat: sendMessageStream()
Chat->>API: generateContentStream()
API-->>Chat: streaming chunks
Chat-->>T: StreamEvent yields
T-->>GC: ServerGeminiStreamEvent yields
GC-->>UI: ServerGeminiStreamEvent yields
GC->>GC: fireAfterAgentHook
sendMessageStream() の処理の流れは次のとおりです:
- フック状態の管理 —
BeforeAgentフックを発火し、実行を停止またはブロックすべきかを確認します - ターン処理 —
processTurn()に処理を委譲し、Turnを生成してモデルのルーティングを管理します - ループ検出 — ターンから受け取ったイベントを
LoopDetectionServiceで検証します - ターン後フック —
AfterAgentフックが実行を停止したり、コンテキストをクリアしたり、継続ターンをトリガーしたりできます - 状態のクリーンアップ —
finallyブロックでフック状態を確実にクリーンアップします
このメソッドは再帰的に動作します。AfterAgent フックが理由を指定して実行をブロックした場合、sendMessageStream はそのブロック理由を新しいプロンプトとして、stopHookActive: true を付けて自身を再帰呼び出しします。
Turn:AsyncGenerator の核心
エージェントループ内でのモデルへの各呼び出しは、Turn インスタンスとしてラップされます。Turn は、API から届く生のストリーミングレスポンスを、型付きの ServerGeminiStreamEvent 値に変換する役割を担います。
line 253 の run() メソッド自体も AsyncGenerator です:
async *run(
modelConfigKey: ModelConfigKey,
req: PartListUnion,
signal: AbortSignal,
displayContent?: PartListUnion,
role: LlmRole = LlmRole.MAIN,
): AsyncGenerator<ServerGeminiStreamEvent>
GeminiChat.sendMessageStream() のレスポンスをイテレートしながら、ストリームイベントの型によってパターンマッチングを行います:
retryイベント →GeminiEventType.Retryとして yield し、UI が部分的なコンテンツを破棄できるようにしますagent_execution_stopped/blocked→ フック由来のコントロールイベントとして yield しますchunkイベント → 思考テキスト、コンテンツテキスト、ツール呼び出し、引用、終了理由をパースします
Turn は pendingToolCalls を蓄積します。これはストリーミングレスポンス内の functionCall パーツから抽出された、Scheduler 経由でのディスパッチが必要なモデルからの関数呼び出しです。
stateDiagram-v2
[*] --> Created: new Turn()
Created --> Running: run() called
Running --> YieldingContent: Content parts received
Running --> YieldingThought: Thought parts received
Running --> CollectingToolCalls: FunctionCall parts received
YieldingContent --> Running: continue iteration
YieldingThought --> Running: continue iteration
CollectingToolCalls --> Running: continue iteration
Running --> Finished: FinishReason received
Running --> Cancelled: signal.aborted
Running --> Error: API error
Finished --> [*]
Cancelled --> [*]
Error --> [*]
ヒント: Turn の
pendingToolCallsは非常に重要です。sendMessageStreamが1ターン分のすべてのイベントを yield し終えると、呼び出し元(通常は UI または SDK)がturn.pendingToolCallsを確認し、Scheduler を通じてツールの実行をディスパッチします。エージェントループは、ツールの結果がフィードバックされて初めて次のターンへ進みます。
ServerGeminiStreamEvent 共用体
ServerGeminiStreamEvent は18種類のイベント型からなる判別可能な共用体(discriminated union)で、line 52–71 の GeminiEventType enum として定義されています:
| イベント型 | 用途 |
|---|---|
Content |
モデルからのストリーミングテキスト |
Thought |
モデルの思考・推論テキスト |
ToolCallRequest |
モデルによるツール実行リクエスト |
ToolCallResponse |
実行されたツールの結果 |
ToolCallConfirmation |
ユーザー承認のための確認詳細 |
ChatCompressed |
コンテキストウィンドウに収めるため圧縮が行われた |
Finished |
ターン完了(理由と使用状況メタデータ付き) |
Retry |
ストリームのリトライ中。部分的なコンテンツを破棄してください |
Error |
エラーが発生した |
UserCancelled |
ユーザーが操作を中止した |
LoopDetected |
無限ループが検出された |
MaxSessionTurns |
セッションのターン上限に達した |
Citation |
モデルからの引用・グラウンディングメタデータ |
ContextWindowWillOverflow |
トークン残量が不足している |
InvalidStream |
ストリームレスポンスが無効だった |
ModelInfo |
使用中のモデルを報告する |
AgentExecutionStopped |
フックが実行を停止した |
AgentExecutionBlocked |
フックが実行をブロックした |
この共用体は、バックエンドとあらゆるフロントエンドの間のコントラクトです。CLI の React コンポーネント、非インタラクティブハンドラー、SDK のいずれも、この同一のイベントストリームを消費し、event.type によるパターンマッチングでそれぞれの動作を制御します。
GeminiChat:低レベルのセッション管理
GeminiChat は最下層に位置します。会話履歴を保持し、ストリームの途中でのリトライを処理する、@google/genai SDK のラッパーです。このファイルは上流の chats.ts からフォークして改変されたものであり、関数レスポンスが有効なレスポンスとして扱われないバグを回避するために作られました。
最大の特徴は、line 89–93 で設定されたストリーム途中リトライのロジックです:
const MID_STREAM_RETRY_OPTIONS: MidStreamRetryOptions = {
maxAttempts: 4, // 1 initial call + 3 retries mid-stream
initialDelayMs: 1000,
useExponentialBackoff: true,
};
ストリームがレスポンスの途中で失敗した場合(ネットワーク切断や無効なコンテンツなど)、GeminiChat は RETRY イベントを yield し、部分的な結果を破棄して、指数バックオフでリトライします。モデルフォールバックもサポートしており、プライマリモデルが繰り返し失敗した場合は、handleFallback ユーティリティを通じて代替モデルへ切り替えられます。
sequenceDiagram
participant GChat as GeminiChat
participant CG as ContentGenerator
participant API as Gemini API
GChat->>CG: generateContentStream(request)
CG->>API: HTTP stream
API-->>CG: chunk 1
CG-->>GChat: yield chunk 1
API--x CG: connection error
GChat->>GChat: yield RETRY event
GChat->>GChat: backoff (1000ms)
GChat->>CG: generateContentStream(request) [attempt 2]
CG->>API: HTTP stream
API-->>CG: full response
CG-->>GChat: yield chunks
ContentGenerator インターフェース は実際の API 呼び出しを抽象化します。実装には、標準的な GoogleGenAI バックエンドのジェネレーター、デバッグトレース用の LoggingContentGenerator、セッション再生用の RecordingContentGenerator、テスト用の FakeContentGenerator が含まれます。
チャット圧縮とループ検出
エージェントループが暴走するのを防ぐ、2つの安全機構があります。
チャット圧縮
会話履歴がモデルのコンテキストウィンドウの上限に近づくと、GeminiClient.processTurn() が圧縮をトリガーします。ChatCompressionService は過去のターンを要約してトークンバジェットを確保します。line 167–185 の CompressionStatus enum が結果を追跡します:
COMPRESSED— 要約が古い履歴の置き換えに成功したCOMPRESSION_FAILED_INFLATED_TOKEN_COUNT— 要約の方が実際には長かったCONTENT_TRUNCATED— 前回の圧縮が失敗したため、コンテンツをバジェット内に切り詰めた
flowchart TD
A[processTurn starts] --> B{Context management enabled?}
B -- Yes --> C[AgentHistoryProvider.manageHistory]
B -- No --> D[tryCompressChat]
D --> E{Compression succeeded?}
E -- Yes --> F[Yield ChatCompressed event]
E -- No --> G[Track failure for future truncation]
C --> H[Check remaining token count]
F --> H
G --> H
H --> I{Request fits in window?}
I -- Yes --> J[Continue with model call]
I -- No --> K[Yield ContextWindowWillOverflow]
注目すべき点として、圧縮が一度失敗すると、クライアントは hasFailedCompressionAttempt = true をセットします。以降の試行では圧縮を再試行せず、コンテンツの切り詰めにフォールバックします。
ループ検出
LoopDetectionService はターンをまたいでイベントのパターンを監視します。line 688 では、各ターン開始前に繰り返しパターンをチェックします。ループカウントが1(早期警告)の場合、クライアントは _recoverFromLoop() で回復を試みます。カウントが1を超えると、LoopDetected イベントを yield して処理を中止します。
さらに、ターン内でも(line 757)各ストリーミングイベントがリアルタイム監視のためにループ検出器に渡されます。これにより、モデルが同じツール呼び出しを繰り返すといった、ターンの途中で発生するループも検出できます。
フックの統合ポイント
sendMessageStream() で見たとおり、フックはエージェントループの2つの重要なポイントに介入します:
BeforeAgent — プロンプトシーケンスにおける最初のモデル呼び出しの前に発火します。できること:
- 実行を完全に停止する(
AgentExecutionStoppedを yield) - 理由を指定して実行をブロックする(
AgentExecutionBlockedを yield) - リクエストに追加のコンテキストを注入する
AfterAgent — pending なツール呼び出しがない状態でモデルが応答した後に発火します。できること:
- 実行を停止し、オプションでコンテキストをクリアする
- ブロックして、新しいプロンプトで継続ターンをトリガーする
- 実行をそのまま続行させる
フック状態は prompt_id ごとに Map で追跡され、sendMessageStream の再帰的な性質に対応します。activeCalls カウンターにより、継続ターンのために再帰呼び出しされた場合でも、BeforeAgent は1プロンプトにつき一度だけ発火します。
さらに2つのフックポイント——BeforeModel と AfterModel ——が GeminiChat レベルで動作し、個々の API 呼び出しに介入します。BeforeModel はリクエストの設定を変更したり、合成レスポンスを返したりできます。AfterModel はレスポンスを変換したり、追加のアクションをトリガーしたりできます。フックシステム全体については第5回で詳しく解説します。
ヒント: GeminiClient の
hookStateMap(line 143)は、フックの重複発火を防ぐ仕組みを理解する鍵です。複数ターンにまたがっているのにフックが一度しか発火しない理由をデバッグしているなら、hasFiredBeforeAgentとactiveCallsを確認してみましょう。
次回の記事では、モデルがツール呼び出しをリクエストした後に何が起きるかを追っていきます。ツールシステムのビルダーパターンと、Scheduler のイベント駆動型オーケストレーションによるツール呼び出しの検証・確認・実行の流れを探っていきましょう。