Gemini CLIのコードを読んで内部処理を理解する

に公開

Googleが開発したGemini CLIは、ターミナルから直接AI支援を受けられる強力なツールです。

自身の理解のために、geminiコマンドを実行してからレスポンスが表示されるまでの内部処理をコードを参照しながら読んでいき、ログを残しておく。

アーキテクチャ概要

Gemini CLIは、モノレポ構造で主に2つのパッケージから構成されている:

  • packages/cli: フロントエンド層(UI、ユーザー入力処理)
  • packages/core: バックエンド層(API通信、ツール実行)
gemini-cli/
├── packages/
│   ├── cli/          # UI層:React + Ink
│   │   ├── src/
│   │   │   ├── gemini.tsx    # メインエントリー
│   │   │   └── ui/           # UIコンポーネント
│   └── core/         # ビジネスロジック層
│       └── src/
│           ├── core/         # API通信
│           └── tools/        # ファイル操作等

エントリーポイント:コマンドの起動

geminiコマンドを実行すると、まずpackages/cli/index.tsが呼び出される:

#!/usr/bin/env node
// packages/cli/index.ts

import { main } from './src/gemini.js';

// グローバルエントリーポイント
main().catch((error) => {
  console.error('An unexpected critical error occurred:');
  if (error instanceof Error) {
    console.error(error.stack);
  } else {
    console.error(String(error));
  }
  process.exit(1);
});

このシンプルなエントリーポイントは、main()関数を呼び出し、エラー処理を行う。

初期化処理:環境のセットアップ

main()関数では、以下の初期化処理が行われる:

// packages/cli/src/gemini.tsx

export async function main() {
  const workspaceRoot = process.cwd();
  const settings = loadSettings(workspaceRoot);

  // 設定エラーのチェック
  if (settings.errors.length > 0) {
    for (const error of settings.errors) {
      console.error(`Error in ${error.path}: ${error.message}`);
    }
    process.exit(1);
  }

  // 拡張機能の読み込み
  const extensions = loadExtensions(workspaceRoot);
  const config = await loadCliConfig(settings.merged, extensions, sessionId);

  // 認証方法の自動設定(環境変数から)
  if (!settings.merged.selectedAuthType && process.env.GEMINI_API_KEY) {
    settings.setValue(
      SettingScope.User,
      'selectedAuthType',
      AuthType.USE_GEMINI,
    );
  }

  // メモリ最適化設定
  const memoryArgs = settings.merged.autoConfigureMaxOldSpaceSize
    ? getNodeMemoryArgs(config)
    : [];

  // TTYチェック:対話型モードかどうか
  if (process.stdin.isTTY && input?.length === 0) {
    render(
      <React.StrictMode>
        <AppWrapper
          config={config}
          settings={settings}
          startupWarnings={startupWarnings}
        />
      </React.StrictMode>,
      { exitOnCtrlC: false },
    );
    return;
  }
}

重要なポイント:

  • 設定ファイル(.gemini)の読み込み
  • 認証方法の決定(API Key、OAuth、Vertex AI)
  • メモリ最適化(必要に応じてNode.jsを再起動)
  • TTY判定による対話型/非対話型モードの切り替え
    • 非対話型のワンショットの起動もサポートしている

UI層:React×Inkによるターミナルインターフェース

対話型モードでは、InkライブラリとReactを使用してターミナルUIを構築している:

// packages/cli/src/ui/App.tsx

const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
  const { history, addItem, clearItems, loadHistory } = useHistory();
  const [streamingState, setStreamingState] = useState(StreamingState.Idle);
  
  // Geminiストリーム処理のフック
  const {
    submitQuery,
    pendingHistoryItems,
    isResponding,
    thought,
  } = useGeminiStream(
    geminiClient,
    history,
    addItem,
    setShowHelp,
    config,
    onDebugMessage,
    handleSlashCommand,
    shellModeActive,
    getPreferredEditor,
    onAuthError,
    performMemoryRefresh,
  );

  // UIレンダリング
  return (
    <StreamingContext.Provider value={streamingState}>
      <Box flexDirection="column" marginBottom={1} width="90%">
        <Static key={staticKey} items={staticHistoryItems}>
          {(item) => (
            <HistoryItemDisplay
              key={item.id}
              item={item}
              config={config}
            />
          )}
        </Static>
        
        <InputPrompt
          value={buffer.text}
          onChange={handleInputChange}
          onSubmit={handleSubmit}
          placeholder="Ask Gemini..."
        />
      </Box>
    </StreamingContext.Provider>
  );
};

InkのStaticコンポーネントにより、過去の会話履歴は静的に表示され、新しい入力のみが再レンダリングされる。

ユーザー入力の処理フロー

ユーザーが入力を送信すると、useGeminiStreamフックが処理を開始する:

// packages/cli/src/ui/hooks/useGeminiStream.ts

const submitQuery = useCallback(
  async (query: PartListUnion, options?: { isContinuation: boolean }) => {
    if (streamingState === StreamingState.Responding && !options?.isContinuation)
      return;

    const userMessageTimestamp = Date.now();
    setShowHelp(false);

    // 中断制御用のAbortController
    abortControllerRef.current = new AbortController();
    const abortSignal = abortControllerRef.current.signal;

    // クエリの準備(@コマンドや/コマンドの処理)
    const { queryToSend, shouldProceed } = await prepareQueryForGemini(
      query,
      userMessageTimestamp,
      abortSignal,
    );

    if (!shouldProceed || queryToSend === null) {
      return;
    }

    setIsResponding(true);
    setInitError(null);

    try {
      // Core層へのストリーミングリクエスト
      const stream = geminiClient.sendMessageStream(queryToSend, abortSignal);
      const processingStatus = await processGeminiStreamEvents(
        stream,
        userMessageTimestamp,
        abortSignal,
      );

      if (pendingHistoryItemRef.current) {
        addItem(pendingHistoryItemRef.current, userMessageTimestamp);
        setPendingHistoryItem(null);
      }
    } catch (error: unknown) {
      if (error instanceof UnauthorizedError) {
        onAuthError();
      } else if (!isNodeError(error) || error.name !== 'AbortError') {
        addItem({
          type: MessageType.ERROR,
          text: parseAndFormatApiError(
            getErrorMessage(error) || 'Unknown error',
            config.getContentGeneratorConfig().authType,
          ),
        }, userMessageTimestamp);
      }
    } finally {
      setIsResponding(false);
    }
  },
  [streamingState, prepareQueryForGemini, geminiClient, ...]
);

Core層:ビジネスロジックとAPI通信

Core層のGeminiClientがAPI通信を管理する:

// packages/core/src/core/client.ts

export class GeminiClient {
  private chat?: GeminiChat;
  private contentGenerator?: ContentGenerator;

  async *sendMessageStream(
    request: PartListUnion,
    signal: AbortSignal,
    turns: number = this.MAX_TURNS,
  ): AsyncGenerator<ServerGeminiStreamEvent, Turn> {
    // 会話履歴の圧縮チェック
    const compressed = await this.tryCompressChat();
    if (compressed) {
      yield { type: GeminiEventType.ChatCompressed, value: compressed };
    }

    // Turnオブジェクトで1ターンの会話を管理
    const turn = new Turn(this.getChat());
    const resultStream = turn.run(request, signal);
    
    // ストリーミングイベントをyield
    for await (const event of resultStream) {
      yield event;
    }

    // 自動継続チェック(モデルがさらに話したい場合)
    if (!turn.pendingToolCalls.length && signal && !signal.aborted) {
      const nextSpeakerCheck = await checkNextSpeaker(
        this.getChat(),
        this,
        signal,
      );
      if (nextSpeakerCheck?.next_speaker === 'model') {
        const nextRequest = [{ text: 'Please continue.' }];
        yield* this.sendMessageStream(nextRequest, signal, turns - 1);
      }
    }
    
    return turn;
  }
}

実際のAPI通信はGeminiChatクラスで行われる:

// packages/core/src/core/geminiChat.ts

async sendMessageStream(
  params: SendMessageParameters,
): Promise<AsyncGenerator<GenerateContentResponse>> {
  await this.sendPromise;
  
  // 履歴を含むリクエストコンテンツの構築
  const userContent = createUserContent(params.message);
  const requestContents = this.getHistory(true).concat(userContent);
  
  this._logApiRequest(requestContents, this.config.getModel());
  const startTime = Date.now();

  try {
    // ContentGenerator経由でAPI呼び出し
    const apiCall = () =>
      this.contentGenerator.generateContentStream({
        model: this.config.getModel(),
        contents: requestContents,
        config: { ...this.generationConfig, ...params.config },
      });

    // リトライ処理付きでストリームを取得
    const streamResponse = await retryWithBackoff(apiCall, {
      shouldRetry: (error: Error) => {
        if (error && error.message) {
          if (error.message.includes('429')) return true;
          if (error.message.match(/5\d{2}/)) return true;
        }
        return false;
      },
      onPersistent429: async (authType?: string) =>
        await this.handleFlashFallback(authType),
      authType: this.config.getContentGeneratorConfig()?.authType,
    });

    // ストリーミングレスポンスの処理
    const result = this.processStreamResponse(
      streamResponse,
      userContent,
      startTime,
    );
    return result;
  } catch (error) {
    const durationMs = Date.now() - startTime;
    this._logApiError(durationMs, error);
    throw error;
  }
}

ストリーミングレスポンスの処理

APIからのレスポンスは、Turnクラスでイベントに変換される:

// packages/core/src/core/turn.ts

async *run(
  req: PartListUnion,
  signal: AbortSignal,
): AsyncGenerator<ServerGeminiStreamEvent> {
  const startTime = Date.now();
  
  try {
    const responseStream = await this.chat.sendMessageStream({
      message: req,
      config: { abortSignal: signal },
    });

    for await (const resp of responseStream) {
      if (signal?.aborted) {
        yield { type: GeminiEventType.UserCancelled };
        return;
      }

      // 思考(thinking)パートの処理
      const thoughtPart = resp.candidates?.[0]?.content?.parts?.[0];
      if (thoughtPart?.thought) {
        const rawText = thoughtPart.text ?? '';
        const subjectStringMatches = rawText.match(/\*\*(.*?)\*\*/s);
        const subject = subjectStringMatches ? subjectStringMatches[1].trim() : '';
        const description = rawText.replace(/\*\*(.*?)\*\*/s, '').trim();
        
        yield {
          type: GeminiEventType.Thought,
          value: { subject, description },
        };
        continue;
      }

      // テキストコンテンツ
      const text = getResponseText(resp);
      if (text) {
        yield { type: GeminiEventType.Content, value: text };
      }

      // ツール呼び出し
      const functionCalls = resp.functionCalls ?? [];
      for (const fnCall of functionCalls) {
        const event = this.handlePendingFunctionCall(fnCall);
        if (event) {
          yield event;
        }
      }

      // 使用統計情報
      if (resp.usageMetadata) {
        this.lastUsageMetadata = resp.usageMetadata;
      }
    }

    // 最終的な使用統計を送信
    if (this.lastUsageMetadata) {
      const durationMs = Date.now() - startTime;
      yield {
        type: GeminiEventType.UsageMetadata,
        value: { ...this.lastUsageMetadata, apiTimeMs: durationMs },
      };
    }
  } catch (e) {
    const error = toFriendlyError(e);
    if (error instanceof UnauthorizedError) {
      throw error;
    }
    
    yield { 
      type: GeminiEventType.Error, 
      value: { error: { message: getErrorMessage(error) } } 
    };
  }
}

最後に、UIでこれらのイベントを処理して表示する:

// packages/cli/src/ui/hooks/useGeminiStream.ts

const processGeminiStreamEvents = useCallback(
  async (
    stream: AsyncIterable<GeminiEvent>,
    userMessageTimestamp: number,
    signal: AbortSignal,
  ): Promise<StreamProcessingStatus> => {
    let geminiMessageBuffer = '';
    const toolCallRequests: ToolCallRequestInfo[] = [];
    
    for await (const event of stream) {
      switch (event.type) {
        case ServerGeminiEventType.Thought:
          setThought(event.value);
          break;
          
        case ServerGeminiEventType.Content:
          // バッファリングして表示を更新
          geminiMessageBuffer = handleContentEvent(
            event.value,
            geminiMessageBuffer,
            userMessageTimestamp,
          );
          break;
          
        case ServerGeminiEventType.ToolCallRequest:
          toolCallRequests.push(event.value);
          break;
          
        case ServerGeminiEventType.Error:
          handleErrorEvent(event.value, userMessageTimestamp);
          break;
          
        case ServerGeminiEventType.UsageMetadata:
          addUsage(event.value);
          break;
      }
    }
    
    // ツール実行のスケジューリング
    if (toolCallRequests.length > 0) {
      scheduleToolCalls(toolCallRequests, signal);
    }
    
    return StreamProcessingStatus.Completed;
  },
  [handleContentEvent, handleErrorEvent, scheduleToolCalls, addUsage]
);

設計上のポイントメモ

メモリ効率を重視したストリーミング設計

従来のCLIツールでは、APIレスポンス全体を受信してから表示するのが一般的だが、Gemini CLIは AsyncGenerator によるストリーミング処理を採用している:

// packages/core/src/core/turn.ts
async *run(req: PartListUnion, signal: AbortSignal): AsyncGenerator<ServerGeminiStreamEvent> {
  for await (const resp of responseStream) {
    const text = getResponseText(resp);
    if (text) {
      yield { type: GeminiEventType.Content, value: text };
    }
  }
}

この設計により:

  • リアルタイム性: ユーザーは即座にレスポンスを確認できる
  • メモリ効率: 大きなレスポンスでもメモリ使用量を抑制
  • 中断可能性: AbortControllerにより適切な処理中断が可能

Reactエコシステムを活用したCLI開発 (Inkの活用)

通常、CLIツールは生のANSIエスケープシーケンスやcursesライブラリで構築されるが、Gemini CLIは React + Ink というアプローチを採用:

// packages/cli/src/ui/App.tsx
return (
  <StreamingContext.Provider value={streamingState}>
    <Box flexDirection="column" marginBottom={1} width="90%">
      <Static key={staticKey} items={staticHistoryItems}>
        {(item) => (
          <HistoryItemDisplay key={item.id} item={item} config={config} />
        )}
      </Static>
      <InputPrompt onSubmit={handleSubmit} placeholder="Ask Gemini..." />
    </Box>
  </StreamingContext.Provider>
);

この選択の利点:

  • 宣言的UI: 複雑な状態管理をReactのパラダイムで解決
  • コンポーネント化: 再利用可能で保守性の高いUI構築
  • エコシステム活用: Reactの豊富なツールチェーンとパターンを利用

イベント駆動アーキテクチャによる責務分離

各処理フェーズを明確なイベントとして定義することで、関心事を適切に分離:

// GeminiEventType の定義
export enum GeminiEventType {
  Content = 'content',           // テキストコンテンツ
  ToolCallRequest = 'tool_call_request', // ツール実行要求
  ToolCallResponse = 'tool_call_response', // ツール実行結果
  Error = 'error',               // エラー
  UsageMetadata = 'usage_metadata', // 使用統計
  Thought = 'thought',           // 思考プロセス
}

この設計により:

  • 疎結合: 各レイヤーが独立して進化可能
  • 拡張性: 新しいイベントタイプの追加が容易
  • テスタビリティ: 各イベントハンドラーを個別にテスト可能

エラーレジリエンスとフォールバック機構

様々なエラーシナリオに対する対策が実装されている:

// packages/core/src/core/geminiChat.ts
const streamResponse = await retryWithBackoff(apiCall, {
  shouldRetry: (error: Error) => {
    if (error && error.message) {
      if (error.message.includes('429')) return true; // レート制限
      if (error.message.match(/5\d{2}/)) return true; // サーバーエラー
    }
    return false;
  },
  onPersistent429: async (authType?: string) =>
    await this.handleFlashFallback(authType), // Flash model へのフォールバック
});

特に印象的なのは Flash Fallback の実装:

  • レート制限が継続する場合、より軽量な Flash モデルに自動切り替え
  • ユーザーに明確な通知を行い、透明性を確保
  • 認証方法に応じた適切なフォールバック戦略

設定管理の階層化

Gemini CLIは複数レベルの設定を適切に管理している:

// packages/cli/src/config/settings.ts
export enum SettingScope {
  User = 'user',           // ~/.gemini/settings.json
  Workspace = 'workspace', // .gemini/settings.json
  Extension = 'extension', // 拡張機能由来
}

これにより:

  • 柔軟性: プロジェクト固有とグローバル設定の使い分け
  • 拡張性: プラグインシステムでの設定統合
  • 優先順位: 適切な設定値の解決順序

TypeScriptでの型安全性

単なる型注釈を超えて、ビジネスロジックレベルでの型安全性を実現:

// packages/core/src/core/turn.ts
export type ServerGeminiContentEvent = {
  type: GeminiEventType.Content;
  value: string;
};

export type ServerGeminiThoughtEvent = {
  type: GeminiEventType.Thought;
  value: ThoughtSummary;
};

// Union type による exhaustive checking
export type ServerGeminiStreamEvent = 
  | ServerGeminiContentEvent
  | ServerGeminiThoughtEvent
  | /* その他のイベント型 */;

コンパイル時にイベントハンドリングの網羅性をチェックし、ランタイムエラーを防止。

パフォーマンス最適化の細かな配慮

単純な実装ではなく、パフォーマンスへの細かな配慮が随所に見られる:

  • Static コンポーネント: 過去の会話履歴は再レンダリング対象外
  • バッファリング: 細かなテキストチャンクを効率的に結合
  • メモリ管理: Node.js ヒープサイズの動的調整
// packages/cli/src/gemini.tsx
const memoryArgs = settings.merged.autoConfigureMaxOldSpaceSize
  ? getNodeMemoryArgs(config)
  : [];

if (memoryArgs.length > 0) {
  await relaunchWithAdditionalArgs(memoryArgs); // より多くのメモリで再起動
}

まとめ:非同期処理とイベント駆動の妙

Gemini CLIの処理フローは、以下の特徴的な設計により実現されている:

  1. レイヤー分離: CLI層(UI)とCore層(ビジネスロジック)の明確な分離
  2. 非同期ストリーミング: AsyncGeneratorによるメモリ効率的なストリーミング処理
  3. イベント駆動: 各種イベント(Content、Tool、Error等)を個別に処理
  4. React×Ink: ターミナルでもReactの宣言的UIを実現
  5. 履歴管理: 会話履歴を保持し、コンテキストとして活用

この設計により、ユーザーはターミナルで快適にAIとの対話を行え、長い応答でもリアルタイムに表示が更新される体験を実現している。

Discussion