Skip to content

Instantly share code, notes, and snippets.

@ryogrid
Last active December 6, 2025 00:38
Show Gist options
  • Select an option

  • Save ryogrid/16d5abef3df92c969d2ef3a598973b03 to your computer and use it in GitHub Desktop.

Select an option

Save ryogrid/16d5abef3df92c969d2ef3a598973b03 to your computer and use it in GitHub Desktop.
OSSなコーディングエージェントツールにおけるコンテキストコンパクション処理について

コンテキストCompaction処理 実装概要

概要

Gemini CLIのコンテキストCompaction(圧縮)機能は、会話履歴がモデルのトークン制限に近づいた際に、 古い会話履歴を要約してコンテキストウィンドウを効率的に利用するための機能です。

アーキテクチャ概要

┌─────────────────┐     ┌──────────────────────────┐     ┌─────────────────┐
│  GeminiClient   │────▶│ ChatCompressionService   │────▶│   Gemini API    │
│  (client.ts)    │     │ (chatCompressionService) │     │ (要約生成用)    │
└─────────────────┘     └──────────────────────────┘     └─────────────────┘
        │                          │
        │                          ▼
        │               ┌──────────────────────────┐
        │               │  compressionPrompt       │
        │               │  (prompts.ts)            │
        │               └──────────────────────────┘
        ▼
┌─────────────────┐
│   tokenLimits   │
│ (tokenLimits.ts)│
└─────────────────┘

主要コンポーネント

1. ChatCompressionService

ファイル: packages/core/src/services/chatCompressionService.ts

Compaction処理の中核を担うサービスクラス。

定数

定数名 説明
DEFAULT_COMPRESSION_TOKEN_THRESHOLD 0.5 圧縮を開始するトークン使用率の閾値(モデル制限の50%)
COMPRESSION_PRESERVE_THRESHOLD 0.3 圧縮後に保持する最新履歴の割合(30%)

2. トークン制限管理

ファイル: packages/core/src/core/tokenLimits.ts

各モデルのトークン制限を定義:

モデル トークン制限
gemini-1.5-pro 2,097,152
gemini-2.5-pro/flash/flash-lite 1,048,576
デフォルト 1,048,576

3. トークン推定

ファイル: packages/core/src/utils/tokenCalculation.ts

ASCII文字:      0.25 トークン/文字
非ASCII文字:    1.3 トークン/文字(CJK文字等)

Compactionアルゴリズム

フェーズ1: 圧縮判定

if (estimatedRequestTokenCount > remainingTokenCount * 0.95) {
    // 自動圧縮をトリガー
    tryCompressChat(promptId, force=false)
}

または、ユーザーが手動で /compress コマンドを実行した場合:

tryCompressChat(promptId, force=true)

フェーズ2: 圧縮閾値チェック

// force=falseの場合のみ閾値チェック
if (!force) {
    const threshold = config.getCompressionThreshold() ?? 0.5;
    if (originalTokenCount < threshold * tokenLimit(model)) {
        return NOOP;  // 圧縮不要
    }
}

フェーズ3: 分割点の決定(findCompressSplitPoint)

履歴を「圧縮する部分」と「保持する部分」に分割するアルゴリズム:

入力: contents[] - 会話履歴
     fraction - 圧縮対象の割合(1 - COMPRESSION_PRESERVE_THRESHOLD = 0.7)

アルゴリズム:
1. 各コンテンツの文字数をカウント
2. 総文字数の fraction (70%) を目標文字数とする
3. 先頭から順にスキャン:
   - userロール かつ functionResponseを含まない場合、有効な分割点としてマーク
   - 累積文字数が目標を超えた時点で、その位置を分割点として返す
4. 分割点が見つからない場合:
   - 最後がmodelロール かつ functionCallがない → 全履歴を圧縮
   - それ以外 → 最後の有効な分割点を使用

制約:
- 分割点は必ずuserメッセージ(functionResponse以外)の境界
- モデルがfunctionCallを実行中の場合は、その直前までしか圧縮しない

図解:

会話履歴: [U1, M1, U2, M2, U3, M3, U4, M4, U5, M5]
          ├──────────────────────┼─────────────────┤
          │   圧縮対象 (70%)     │  保持 (30%)     │
          │                      │                  │
          ▼                      ▼                  ▼
          splitPoint             historyToKeep

フェーズ4: 要約生成

圧縮対象の履歴をLLMに送信し、構造化された要約を生成:

const summaryResponse = await config.getBaseLlmClient().generateContent({
    modelConfigKey: { model: modelStringToModelConfigAlias(model) },
    contents: [
        ...historyToCompress,
        {
            role: 'user',
            parts: [{ text: 'First, reason in your scratchpad. Then, generate the <state_snapshot>.' }]
        }
    ],
    systemInstruction: { text: getCompressionPrompt() }
});

圧縮モデルのマッピング

メインモデル 圧縮用モデル
gemini-3-pro-preview chat-compression-3-pro
gemini-2.5-pro chat-compression-2.5-pro
gemini-2.5-flash chat-compression-2.5-flash
gemini-2.5-flash-lite chat-compression-2.5-flash-lite
その他 chat-compression-default

フェーズ5: 要約形式(State Snapshot)

生成される要約のXML構造:

<state_snapshot>
    <overall_goal>
        <!-- ユーザーの高レベル目標(1文) -->
        例: "認証サービスを新しいJWTライブラリを使用するようにリファクタリングする"
    </overall_goal>

    <key_knowledge>
        <!-- 重要な事実、規約、制約 -->
        - ビルドコマンド: `npm run build`
        - テスト: `npm test` で実行。テストファイルは `.test.ts` で終わる
        - APIエンドポイント: `https://api.example.com/v2`
    </key_knowledge>

    <file_system_state>
        <!-- 作成/読み込み/変更/削除されたファイルの状態 -->
        - CWD: `/home/user/project/src`
        - READ: `package.json` - 'axios' が依存関係であることを確認
        - MODIFIED: `services/auth.ts` - 'jsonwebtoken' を 'jose' に置換
        - CREATED: `tests/new-feature.test.ts` - 新機能のテスト構造
    </file_system_state>

    <recent_actions>
        <!-- 最近の重要なエージェントアクションとその結果 -->
        - `grep 'old_function'` を実行、2ファイルで3件の結果
        - `npm run test` を実行、UserProfile.test.ts でスナップショット不一致
        - `ls -F static/` で画像アセットが .webp 形式であることを発見
    </recent_actions>

    <current_plan>
        <!-- ステップバイステップの計画と進捗状況 -->
        1. [DONE] 非推奨の 'UserAPI' を使用しているファイルを特定
        2. [IN PROGRESS] src/components/UserProfile.tsx を新しい 'ProfileAPI' を使用するようリファクタリング
        3. [TODO] 残りのファイルをリファクタリング
        4. [TODO] テストをAPI変更に合わせて更新
    </current_plan>
</state_snapshot>

フェーズ6: 新履歴の構築

const newHistory = [
    {
        role: 'user',
        parts: [{ text: summary }]  // 生成された要約
    },
    {
        role: 'model',
        parts: [{ text: 'Got it. Thanks for the additional context!' }]
    },
    ...historyToKeep  // 保持された最新履歴(30%)
];

フェーズ7: 検証と適用

// トークン数を再計算
const newTokenCount = await calculateRequestTokenCount(...);

// 圧縮効果の検証
if (newTokenCount > originalTokenCount) {
    // 圧縮により増加 → 失敗
    return COMPRESSION_FAILED_INFLATED_TOKEN_COUNT;
} else {
    // 圧縮成功 → 新履歴を適用
    this.chat = await this.startChat(newHistory);
    return COMPRESSED;
}

圧縮ステータス

enum CompressionStatus {
    COMPRESSED = 1,                              // 成功
    COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,    // トークン増加により失敗
    COMPRESSION_FAILED_TOKEN_COUNT_ERROR,       // トークンカウントエラー
    NOOP                                         // 圧縮不要(アクションなし)
}

フック統合

PreCompressHook

圧縮処理の前に発火するフック:

enum PreCompressTrigger {
    Manual = 'manual',  // /compress コマンド
    Auto = 'auto'       // 自動圧縮
}

ユーザーは圧縮前に会話のバックアップや通知処理を実行可能。

シーケンス図

User            GeminiClient       ChatCompressionService      Gemini API
  │                  │                      │                      │
  │  send message    │                      │                      │
  │─────────────────▶│                      │                      │
  │                  │                      │                      │
  │                  │ check token usage    │                      │
  │                  │ (>95% capacity?)     │                      │
  │                  │──────┐               │                      │
  │                  │      │               │                      │
  │                  │◀─────┘               │                      │
  │                  │                      │                      │
  │                  │   compress()         │                      │
  │                  │─────────────────────▶│                      │
  │                  │                      │                      │
  │                  │                      │ firePreCompressHook  │
  │                  │                      │──────┐               │
  │                  │                      │      │               │
  │                  │                      │◀─────┘               │
  │                  │                      │                      │
  │                  │                      │ findCompressSplitPoint
  │                  │                      │──────┐               │
  │                  │                      │      │               │
  │                  │                      │◀─────┘               │
  │                  │                      │                      │
  │                  │                      │   generateContent    │
  │                  │                      │─────────────────────▶│
  │                  │                      │                      │
  │                  │                      │      summary         │
  │                  │                      │◀─────────────────────│
  │                  │                      │                      │
  │                  │   CompressionInfo    │                      │
  │                  │◀─────────────────────│                      │
  │                  │                      │                      │
  │  ChatCompressed  │                      │                      │
  │◀─────────────────│                      │                      │
  │                  │                      │                      │

設定オプション

設定項目 デフォルト値 説明
model.compressionThreshold 0.5 圧縮を開始するトークン使用率

関連ファイル一覧

ファイルパス 役割
packages/core/src/services/chatCompressionService.ts 圧縮サービス本体
packages/core/src/core/client.ts クライアント統合
packages/core/src/core/prompts.ts 圧縮プロンプト定義
packages/core/src/core/turn.ts 型定義(CompressionStatus等)
packages/core/src/core/tokenLimits.ts モデルトークン制限
packages/core/src/utils/tokenCalculation.ts トークン推定
packages/cli/src/ui/commands/compressCommand.ts /compressコマンド
packages/core/src/hooks/types.ts フック型定義

コンテキスト Compaction 処理

概要

Compaction処理は、LLMのコンテキストウィンドウが一杯になった際に、会話履歴を要約・圧縮することで、長時間のセッションを継続可能にする機能です。

この機能は主に2つのフェーズで構成されています:

  1. Prune(刈り込み): 古いツール出力を削除してトークン数を削減
  2. Compact(圧縮): 会話全体をAIモデルで要約

アーキテクチャ

関連ファイル

ファイル 役割
session/compaction.ts コアのcompactionロジック
session/prompt.ts オーバーフロー検知とcompaction呼び出し
session/message-v2.ts メッセージフィルタリングとモデル変換
session/summary.ts セッション・メッセージ要約
session/prompt/compaction.txt 要約用システムプロンプト

処理フロー図

┌─────────────────────────────────────────────────────────────────┐
│                     プロンプト実行ループ                         │
│                      (prompt.ts)                                │
└─────────────────────┬───────────────────────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────────────────────┐
│  1. オーバーフロー検知                                           │
│     SessionCompaction.isOverflow()                              │
│     条件: tokens > context_window - reserved_output             │
└─────────────────────┬───────────────────────────────────────────┘
                      │ オーバーフロー検知時
                      ▼
┌─────────────────────────────────────────────────────────────────┐
│  2. Compactionタスク作成                                         │
│     SessionCompaction.create()                                  │
│     → CompactionPartをユーザーメッセージに追加                    │
└─────────────────────┬───────────────────────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────────────────────┐
│  3. Compaction実行                                               │
│     SessionCompaction.process()                                 │
│     → AIモデルで会話を要約                                       │
│     → summary: true フラグ付きアシスタントメッセージを作成        │
└─────────────────────┬───────────────────────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────────────────────┐
│  4. Prune実行(ステップ完了後)                                   │
│     SessionCompaction.prune()                                   │
│     → 古いツール出力を削除                                       │
└─────────────────────────────────────────────────────────────────┘

アルゴリズム詳細

1. オーバーフロー検知 (isOverflow)

コンテキストウィンドウの使用量がモデルの制限を超えているかを判定します。

function isOverflow(input: {
  tokens: { input, cache: { read }, output },
  model: Provider.Model
}) {
  // 自動compaction無効化フラグ
  if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) return false

  const context = input.model.limit.context  // モデルのコンテキストウィンドウサイズ
  if (context === 0) return false

  // 現在使用中のトークン数
  const count = input.tokens.input + input.tokens.cache.read + input.tokens.output

  // 出力用に確保するトークン数(最大32,000)
  const output = Math.min(input.model.limit.output, OUTPUT_TOKEN_MAX) || OUTPUT_TOKEN_MAX

  // 使用可能なトークン数
  const usable = context - output

  return count > usable
}

判定式:

tokens.input + tokens.cache.read + tokens.output > context_window - reserved_output

2. Prune(刈り込み)処理

古いツール呼び出しの出力を削除し、トークン数を削減します。

定数

const PRUNE_MINIMUM = 20_000  // 最小削除トークン数(これ以上削除できる場合のみ実行)
const PRUNE_PROTECT = 40_000  // 保護トークン数(この量を蓄積するまで削除しない)

アルゴリズム

1. メッセージを新しい順に逆走査
2. ユーザーメッセージをカウント(ターン数)
3. 2ターン以上経過したメッセージのみ対象
4. 完了済みツール呼び出しのトークン数を累計
5. 累計が PRUNE_PROTECT(40K)を超えたら、それより古いツール出力を削除対象に
6. 削除対象のトークン数が PRUNE_MINIMUM(20K)を超えた場合のみ実行
7. 対象ツールパートに compacted タイムスタンプを設定
async function prune(input: { sessionID: string }) {
  let total = 0     // ツール出力の累計トークン数
  let pruned = 0    // 削除予定のトークン数
  let turns = 0     // ターン数

  // 逆順にメッセージを走査
  for (let msgIndex = msgs.length - 1; msgIndex >= 0; msgIndex--) {
    const msg = msgs[msgIndex]

    // ユーザーメッセージでターン数をカウント
    if (msg.info.role === "user") turns++

    // 最初の2ターンは保護
    if (turns < 2) continue

    // サマリーメッセージで停止
    if (msg.info.role === "assistant" && msg.info.summary) break

    for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) {
      const part = msg.parts[partIndex]

      if (part.type === "tool" && part.state.status === "completed") {
        // 既にcompacted済みなら停止
        if (part.state.time.compacted) break

        const estimate = Token.estimate(part.state.output)
        total += estimate

        // PRUNE_PROTECTを超えたら、それより古いものを削除対象に
        if (total > PRUNE_PROTECT) {
          pruned += estimate
          toPrune.push(part)
        }
      }
    }
  }

  // PRUNE_MINIMUM以上削除できる場合のみ実行
  if (pruned > PRUNE_MINIMUM) {
    for (const part of toPrune) {
      part.state.time.compacted = Date.now()
      await Session.updatePart(part)
    }
  }
}

Compacted ツール出力の処理

toModelMessageでモデルに送信する際、compactedされたツール出力は置換されます:

// message-v2.ts:646
output: part.state.time.compacted
  ? "[Old tool result content cleared]"
  : part.state.output

3. Compact(圧縮)処理

AIモデルを使用して会話全体を要約します。

Compactionタスク作成 (create)

async function create(input) {
  // ユーザーメッセージを作成
  const msg = await Session.updateMessage({
    role: "user",
    model: input.model,
    sessionID: input.sessionID,
    agent: input.agent,
    time: { created: Date.now() },
  })

  // CompactionPartを追加
  await Session.updatePart({
    messageID: msg.id,
    sessionID: msg.sessionID,
    type: "compaction",
    auto: input.auto,  // 自動/手動の区別
  })
}

Compaction実行 (process)

async function process(input) {
  // 1. サマリー用アシスタントメッセージを作成(summary: true)
  const msg = await Session.updateMessage({
    role: "assistant",
    summary: true,  // サマリーフラグ
    // ...
  })

  // 2. プロセッサを作成してAIモデルを呼び出し
  const processor = SessionProcessor.create({ ... })

  // 3. システムプロンプト + 過去メッセージ + 要約リクエストを送信
  const result = await processor.process({
    messages: [
      ...SystemPrompt.compaction(model.providerID),  // 要約用システムプロンプト
      ...MessageV2.toModelMessage(input.messages),   // 過去の会話履歴
      {
        role: "user",
        content: "Summarize our conversation above..."  // 要約リクエスト
      },
    ],
    // ...
  })

  // 4. 完了イベントを発行
  Bus.publish(Event.Compacted, { sessionID: input.sessionID })
}

要約用システムプロンプト (compaction.txt)

You are a helpful AI assistant tasked with summarizing conversations.

When asked to summarize, provide a detailed but concise summary of the conversation.
Focus on information that would be helpful for continuing the conversation, including:
- What was done
- What is currently being worked on
- Which files are being modified
- What needs to be done next
- Key user requests, constraints, or preferences that should persist
- Important technical decisions and why they were made

Your summary should be comprehensive enough to provide context
but concise enough to be quickly understood.

4. メッセージフィルタリング (filterCompacted)

Compaction後、古いメッセージは除外され、サマリー以降のみが使用されます。

async function filterCompacted(stream: AsyncIterable<MessageV2.WithParts>) {
  const result = [] as MessageV2.WithParts[]
  const completed = new Set<string>()

  // 最新から逆順に走査
  for await (const msg of stream) {
    result.push(msg)

    // ユーザーメッセージにCompactionPartがあり、
    // 対応するアシスタントメッセージが完了済みなら停止
    if (
      msg.info.role === "user" &&
      completed.has(msg.info.id) &&
      msg.parts.some((part) => part.type === "compaction")
    ) break

    // サマリーメッセージをマーク
    if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish)
      completed.add(msg.info.parentID)
  }

  result.reverse()  // 時系列順に戻す
  return result
}

データ構造

CompactionPart

const CompactionPart = z.object({
  id: z.string(),
  sessionID: z.string(),
  messageID: z.string(),
  type: z.literal("compaction"),
  auto: z.boolean(),  // true: 自動トリガー, false: 手動
})

ToolStateCompleted (compacted timestamp)

const ToolStateCompleted = z.object({
  status: z.literal("completed"),
  input: z.record(z.string(), z.any()),
  output: z.string(),
  title: z.string(),
  metadata: z.record(z.string(), z.any()),
  time: z.object({
    start: z.number(),
    end: z.number(),
    compacted: z.number().optional(),  // Prune時に設定
  }),
  attachments: FilePart.array().optional(),
})

Assistant Message (summary flag)

const Assistant = Base.extend({
  role: z.literal("assistant"),
  // ...
  summary: z.boolean().optional(),  // Compaction要約の場合true
  finish: z.string().optional(),     // 完了理由
  // ...
})

設定フラグ

環境変数 説明
OPENCODE_DISABLE_AUTOCOMPACT 自動compactionを無効化
OPENCODE_DISABLE_PRUNE Prune処理を無効化

トークン推定

// util/token.ts
const CHARS_PER_TOKEN = 4

function estimate(text: string): number {
  return Math.ceil(text.length / CHARS_PER_TOKEN)
}

処理のタイミング

  1. isOverflow: 各ステップ完了後、次のステップを開始する前にチェック
  2. create: オーバーフロー検知時に即座にCompactionタスクを作成
  3. process: プロンプトループ内でCompactionタスクを検出して実行
  4. prune: プロンプトループ終了後(セッション完了時)に実行

まとめ

Compaction処理は以下の目的で設計されています:

  1. 長時間セッションの継続: コンテキストウィンドウの制限を超えても会話を継続
  2. 重要情報の保持: AIによる要約で、タスク継続に必要な情報を保持
  3. 効率的なトークン管理: 古いツール出力を選択的に削除してスペースを確保
  4. 透過的な動作: ユーザーは意識せずに自然な会話を継続可能
@ryogrid
Copy link
Author

ryogrid commented Dec 6, 2025

メモ: ファイル登録日時点の最新のコードベースで作成

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment