Open1

エージェントに記憶を!`logger.ts`を活用した会話履歴管理ツールの作り方

TaiyoTaiyo

Geminiエージェントに記憶を!logger.tsを活用した会話履歴管理ツールの作り方

こんにちは!AIエージェントを開発していると、「前の会話の内容を覚えていてほしいな」と思うことはありませんか?LLMは基本的にステートレスなので、何もしなければ過去のやり取りを忘れてしまいます。これを解決するのが、会話履歴を永続化する仕組みです。

この記事では、Google製のCLIツール gemini-cli に含まれている logger.ts というモジュールを題材に、堅牢な会話履歴管理システムを構築し、それをエージェントが自律的に使える「ツール」として組み込む方法を、具体的なコードを交えて徹底解説します。

1. logger.ts とは? - 会話履歴管理の中核

gemini-clipackages/core/src/core/logger.ts には、Logger というクラスが定義されています。これが会話履歴の永続化を担う心臓部です。まずはその主な機能を見ていきましょう。

主な機能

  • セッションごとのログ記録: sessionId をキーとして、各会話セッションのログを個別に管理します。
  • ファイルへの永続化: 会話ログを logs.json というJSONファイルに保存します。これにより、アプリケーションを再起動しても過去の履歴が消えません。
  • チェックポイント機能: 会話の特定の状態を checkpoint.json として保存・復元できます。これにより、「あの時の会話の続きから」といった操作が可能になります。
  • 堅牢なエラーハンドリング: ログファイルが破損していた場合に備えて、自動でバックアップを作成し、新しいログファイルで処理を継続する仕組みを持っています。

主要なコード解説

Loggerクラスのコンストラクタは sessionId を受け取ります。

packages/core/src/core/logger.ts
// ... existing code ...
export class Logger {
  private geminiDir: string | undefined;
  // ...
  private sessionId: string | undefined;
  // ...
  private initialized = false;
  // ...

  constructor(sessionId: string) {
    this.sessionId = sessionId;
  }
// ... existing code ...

initialize() メソッドで、ログを保存するディレクトリやファイルのパスを設定し、既存のログを読み込みます。

packages/core/src/core/logger.ts
// ... existing code ...
  async initialize(): Promise<void> {
    if (this.initialized) {
      return;
    }

    this.geminiDir = getProjectTempDir(process.cwd());
    this.logFilePath = path.join(this.geminiDir, LOG_FILE_NAME);
    this.checkpointFilePath = path.join(this.geminiDir, CHECKPOINT_FILE_NAME);

    try {
      await fs.mkdir(this.geminiDir, { recursive: true });
      // ...
      this.logs = await this._readLogFile();
      // ...
      this.initialized = true;
    } catch (err) {
      console.error('Failed to initialize logger:', err);
      this.initialized = false;
    }
  }
// ... existing code ...

logMessage() メソッドで、新しいメッセージをログに追加します。内部で _updateLogFile() を呼び出し、アトミックなファイル更新を試みます。これにより、複数のプロセスが同時にログを書き込もうとしても、競合を避けて安全に更新できます。

packages/core/src/core/logger.ts
// ... existing code ...
  async logMessage(type: MessageSenderType, message: string): Promise<void> {
    if (!this.initialized || this.sessionId === undefined) {
      // ...
      return;
    }

    const newEntryObject: LogEntry = {
      sessionId: this.sessionId,
      messageId: this.messageId, // This will be recalculated in _updateLogFile
      type,
      message,
      timestamp: new Date().toISOString(),
    };

    try {
      const writtenEntry = await this._updateLogFile(newEntryObject);
      // ...
    } catch (_error) {
      // ...
    }
  }
// ... existing code ...

そして、特筆すべきはチェックポイント機能です。saveCheckpoint()loadCheckpoint() を使うことで、会話の完全な状態(Content[] 配列)を保存・復元できます。

packages/core/src/core/logger.ts
// ... existing code ...
  async saveCheckpoint(conversation: Content[], tag?: string): Promise<void> {
    if (!this.initialized || !this.checkpointFilePath) {
      // ...
      return;
    }
    const path = this._checkpointPath(tag);
    try {
      await fs.writeFile(path, JSON.stringify(conversation, null), 'utf-8');
    } catch (error) {
      console.error('Error writing to checkpoint file:', error);
    }
  }

  async loadCheckpoint(tag?: string): Promise<Content[]> {
    if (!this.initialized || !this.checkpointFilePath) {
      // ...
      return [];
    }
    const path = this._checkpointPath(tag);
    try {
      const fileContent = await fs.readFile(path, 'utf-8');
      return JSON.parse(fileContent) as Content[];
    } catch (error) {
      // ...
      return [];
    }
  }
// ... existing code ...

2. Logger をエージェントの「ツール」にする

さて、この便利な Logger をエージェント自身が使えるように、「ツール」としてラップしてあげましょう。エージェントがツールを使うことで、人間が直接指示しなくても、必要に応じて自律的に会話履歴を操作できるようになります。

ツール設計

Logger の機能を、以下のようなツールに分割して提供することを考えます。

  • log_user_message(message: string): ユーザーの発言を記録するツール。
  • get_previous_user_messages(): 過去のユーザー発言リストを取得するツール。
  • save_conversation_checkpoint(tag?: string): 現在の会話状態をチェックポイントとして保存するツール。
  • load_conversation_checkpoint(tag?: string): チェックポイントから会話状態を復元するツール。

ツール実装例

ここでは、これらのツールを実装するためのラッパー関数の例を示します。実際のアプリケーションでは、これらの関数をエージェントのツールレジストリに登録することになります。

import { Logger, MessageSenderType } from './logger'; // logger.ts からインポート
import { Content } from '@google/genai';

// Loggerのインスタンスを管理(例: シングルトンやDIコンテナで管理)
const getLoggerForSession = (sessionId: string): Logger => {
  // ここでセッションIDに対応するLoggerインスタンスを取得または生成する
  const logger = new Logger(sessionId);
  logger.initialize(); // 非同期なので実際にはPromiseを扱う必要がある
  return logger;
}

// 現在の会話コンテキストを保持する変数(仮)
let currentConversation: Content[] = [];
let currentSessionId = 'session-12345'; // 仮のセッションID

// --- ツール定義 ---

/**
 * ユーザーの発言をログに記録します。
 * @param message 記録するメッセージ
 */
async function log_user_message(message: string): Promise<void> {
  const logger = getLoggerForSession(currentSessionId);
  await logger.logMessage(MessageSenderType.USER, message);
  console.log(`Logged user message: "${message}"`);
}

/**
 * 過去のユーザー発言のリストを取得します。
 * @returns ユーザー発言の文字列配列
 */
async function get_previous_user_messages(): Promise<string[]> {
  const logger = getLoggerForSession(currentSessionId);
  return await logger.getPreviousUserMessages();
}

/**
 * 現在の会話状態をチェックポイントとして保存します。
 * @param tag チェックポイントを識別するためのオプショナルなタグ
 */
async function save_conversation_checkpoint(tag?: string): Promise<void> {
  const logger = getLoggerForSession(currentSessionId);
  await logger.saveCheckpoint(currentConversation, tag);
  console.log(`Conversation checkpoint saved with tag: "${tag || 'default'}"`);
}

/**
 * チェックポイントから会話状態を復元します。
 * @param tag 復元するチェックポイントのタグ
 * @returns 復元された会話コンテキスト
 */
async function load_conversation_checkpoint(tag?: string): Promise<Content[]> {
  const logger = getLoggerForSession(currentSessionId);
  const loadedConversation = await logger.loadCheckpoint(tag);
  currentConversation = loadedConversation; // グローバルな会話状態を更新
  console.log(`Conversation loaded from checkpoint with tag: "${tag || 'default'}"`);
  return loadedConversation;
}

3. エージェントへの組み込みと活用シナリオ

ツールが定義できたら、エージェントにその存在を教え、使い方を定義します。これは、使用するエージェントフレームワーク(LangChain, LlamaIndex, あるいは自作のものなど)によって具体的な方法は異なりますが、基本的には「ツール名」「説明」「引数のスキーマ」を登録します。

ツール登録のイメージ

const tools = [
  {
    name: 'log_user_message',
    description: 'ユーザーからのメッセージを会話履歴に記録する。',
    input_schema: { type: 'object', properties: { message: { type: 'string' } }, required: ['message'] }
  },
  {
    name: 'get_previous_user_messages',
    description: 'これまでのユーザーからの全メッセージ履歴を取得する。',
    input_schema: { type: 'object', properties: {} }
  },
  {
    name: 'save_conversation_checkpoint',
    description: '後で会話を再開できるように、現在の会話の完全な状態を保存する。',
    input_schema: { type: 'object', properties: { tag: { type: 'string' } } }
  },
  {
    name: 'load_conversation_checkpoint',
    description: '以前に保存した会話の状態を復元し、会話を再開する。',
    input_schema: { type: 'object', properties: { tag: { type: 'string' } } }
  }
];

// この `tools` 配列をエージェントに渡す

活用シナリオ

これらのツールを手に入れたエージェントは、こんなことができるようになります。

  • ユーザー: 「今日の進捗を保存しといて」

  • エージェント: (思考: 「保存」というキーワードから save_conversation_checkpoint ツールが使えそうだ。タグはどうしよう?「今日の進捗」でいいか。) → save_conversation_checkpoint({ tag: 'daily_progress' }) を実行。

  • ユーザー: 「昨日の進捗を思い出させて」

  • エージェント: (思考: 「思い出す」「昨日」というキーワードから load_conversation_checkpoint が必要そうだ。タグは daily_progress を試してみよう。) → load_conversation_checkpoint({ tag: 'daily_progress' }) を実行し、復元された会話内容を元に要約して回答。

まとめ

今回は、gemini-clilogger.ts を例に、エージェントに「記憶」を持たせるための会話履歴管理とツール化の方法を解説しました。

  • 堅牢なロギング機構を構築することで、アプリケーションの信頼性が向上する。
  • ロギング機能を「ツール」としてエージェントに提供することで、エージェントは自律的に会話履歴を管理できるようになる。
  • チェックポイント機能を使えば、複雑なタスクの途中保存や、過去の文脈の復元が容易になる。

ステートフルなAIエージェントを開発する上で、今回紹介したような会話履歴の管理は不可欠な要素です。ぜひ、あなたのエージェントにも「記憶」を授けて、より高度な対話を実現してみてください!