Read OSS

エージェントループの内側:Gemini CLI がプロンプトを処理する仕組み

上級

前提知識

  • 第1回:アーキテクチャとナビゲーションガイド
  • TypeScript の async generator とイテレーター
  • LLM のツール呼び出しとストリーミングの基礎知識

エージェントループの内側:Gemini CLI がプロンプトを処理する仕組み

Gemini CLI にプロンプトを入力すると、そこから数十ターンに及ぶループが始まります。モデルへのリクエスト送信、ストリーミングレスポンスの受信、ツール呼び出しのディスパッチ、結果のフィードバック。このサイクルを、タスクが完了するかモデルが終了を通知するまで繰り返します。単純なリクエスト/レスポンスのサイクルではなく、ストリーミングイベント、リトライロジック、圧縮、ループ検出、フック統合を各段階に備えた3層アーキテクチャです。

この記事では、ユーザープロンプトが GeminiClientTurnGeminiChat を経由する完全なライフサイクルを追っていきます。

GeminiClient:外側ループのオーケストレーター

GeminiClient クラスは、LLM とのすべてのやり取りのエントリーポイントです。AgentLoopContext(第1回で解説)を受け取り、ループ検出、チャット圧縮、ツール出力のマスキング、フックの発火など、会話のライフサイクル全体を管理します。

中心となるメソッドは line 881sendMessageStream() です。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() の処理の流れは次のとおりです:

  1. フック状態の管理BeforeAgent フックを発火し、実行を停止またはブロックすべきかを確認します
  2. ターン処理processTurn() に処理を委譲し、Turn を生成してモデルのルーティングを管理します
  3. ループ検出 — ターンから受け取ったイベントを LoopDetectionService で検証します
  4. ターン後フックAfterAgent フックが実行を停止したり、コンテキストをクリアしたり、継続ターンをトリガーしたりできます
  5. 状態のクリーンアップfinally ブロックでフック状態を確実にクリーンアップします

このメソッドは再帰的に動作します。AfterAgent フックが理由を指定して実行をブロックした場合、sendMessageStream はそのブロック理由を新しいプロンプトとして、stopHookActive: true を付けて自身を再帰呼び出しします。

Turn:AsyncGenerator の核心

エージェントループ内でのモデルへの各呼び出しは、Turn インスタンスとしてラップされます。Turn は、API から届く生のストリーミングレスポンスを、型付きの ServerGeminiStreamEvent 値に変換する役割を担います。

line 253run() メソッド自体も 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–71GeminiEventType 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–185CompressionStatus 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つのフックポイント——BeforeModelAfterModel ——が GeminiChat レベルで動作し、個々の API 呼び出しに介入します。BeforeModel はリクエストの設定を変更したり、合成レスポンスを返したりできます。AfterModel はレスポンスを変換したり、追加のアクションをトリガーしたりできます。フックシステム全体については第5回で詳しく解説します。

ヒント: GeminiClient の hookStateMap(line 143)は、フックの重複発火を防ぐ仕組みを理解する鍵です。複数ターンにまたがっているのにフックが一度しか発火しない理由をデバッグしているなら、hasFiredBeforeAgentactiveCalls を確認してみましょう。

次回の記事では、モデルがツール呼び出しをリクエストした後に何が起きるかを追っていきます。ツールシステムのビルダーパターンと、Scheduler のイベント駆動型オーケストレーションによるツール呼び出しの検証・確認・実行の流れを探っていきましょう。