10 KiB
コンテキスト圧縮 (Compaction)
長時間実行エージェントのコンテキストウィンドウ管理。 Prune(段階的な出力除去)と Compact(履歴の要約置換)の2層で対処する。
全体像
[各リクエストの合間]
LLM リクエスト
↓
PruneHook (pre_llm_request)
→ 古い ToolResult の content を除去(summary は残す)
→ リクエストコンテキストのみ操作、history 本体は不変
↓
CompactInterceptor (pre_llm_request) ← safety net
→ input_tokens > request_threshold なら Yield
→ Worker が WorkerResult::Yielded で正常終了
↓
Pod::handle_worker_result
→ persist_turn(旧セッションに記録)
→ compact() → resume()
[ターンの合間 — 次の Pod::run 冒頭]
Pod::try_pre_run_compact ← proactive
→ input_tokens > post_run_threshold なら compact() (best-effort)
Prune
古い ToolResult の content を None に置換し、summary だけ残す。
設計判断
- 条件付き実行: 推定トークン節約量が
min_savingsを超えた場合のみ。KV キャッシュの無駄な無効化を避ける - リクエストコンテキストのみ操作: history 本体は変更しない。Prune 状態を Pod が保持し、LLM リクエスト構築時に反映する
- 冪等:
content: Noneのアイテムはスキップ
ToolOutput の構造
pub struct ToolOutput {
pub summary: String, // 常に残る。「何をしたか」の最低限の情報
pub content: Option<String>, // Prune 対象。None に置換するだけ
}
Tool::execute() は Result<ToolOutput, ToolError> を返す。
From<String> で自動変換可能(小さい出力は summary のみ、大きい出力は summary + content)。
巨大な出力はフレームワークの責務外。ツール側がファイルに退避し、content に見取り図を置く。
Compact
用語
- run = turn: 同じ概念。1 ユーザープロンプト → 完了までの単位
- リクエスト: 1 turn 内で投げる個別の LLM 呼び出し。ツール使用で 1 turn に複数リクエストが発生する
- リクエストの合間: 1 turn 内、次の LLM リクエストを投げる前の地点
- ターンの合間: turn が完了して次の turn を待つ状態
トリガー(2段階の閾値)
- ターンの合間 (次 turn 冒頭):
try_pre_run_compact()でinput_tokens > post_run_threshold→ best-effort - リクエストの合間 (CompactInterceptor):
pre_llm_requestでinput_tokens > request_threshold→PreRequestAction::Yield
ターンの合間が proactive (小さい閾値):
turn が完了した地点はタスクの自然な区切り。ここで先を見越して早めに compact する。
マニフェストの compact_threshold が対応。
リクエストの合間は safety net (大きい閾値):
turn 内部でリクエストの合間にチェックするのは「暴走的に膨張した場合のみ止める」用途。
マニフェストの compact_request_threshold が対応。通常は発動しない。
両閾値は manifest で個別指定する。過去の設計では 9/8 倍で自動導出していたが、
比率に根拠がなかったため廃止。両方が Option<u64> で、片方だけの設定も可能
(そちら側のチェックだけが有効になる)。
Yield の仕組み
PreRequestAction::Yield は Worker のターンループを正常終了させる汎用プリミティブ。
Worker は Compact を知らない。「Interceptor が yield と言ったので抜ける」だけ。
Pod が Yielded を検知して compact → resume する。
PreRequestAction::Yield → WorkerResult::Yielded → Pod が compact → resume
PreRequestAction::Cancel → WorkerError::Aborted → エラー
安全機構
- サーキットブレーカー: 3回連続 compact 失敗で無効化 (
CompactState::disabled) - Thrash 検出: compact 直後に再び閾値超過 →
PodError::CompactThrash - Yield 前の永続化:
persist_turnを compact の前に実行。失敗しても旧セッションにデータが残る
セッション管理
compact は fork と同じ構造。旧セッションを保全し、新 SessionId で圧縮後のセッションを開始。
旧セッション (abc-123):
[...entries...] → Outcome::Yielded (interrupted=true) ← そのまま残る
新セッション (def-456):
[SessionStart { compacted_from: (abc-123, entryN.hash), history: [要約 + 直近] }] → ...
SessionStart に forked_from / compacted_from フィールドで出自を追跡可能。
compact 後の history 構造
全て system message(Item::Message { role: System })として注入。
[system prompt] ← 不変
[system: 構造化要約] ← compact worker の出力
[system: auto-read ファイル群] ← read_required の結果
[system: リファレンス一覧] ← reference の結果
[直近 N トークン分の生の会話] ← pruned 状態で保持
直近の保護: トークンベース
ターン単位ではなくトークン数ベースで直近の会話を保護する。
ターン単位の問題: 自走エージェントは1ターン内で多数のリクエストを回す。 1ターンが膨大に長くなり、保護量がターン長に依存してしまう。 トークン数ベースなら、ターンの長さに関わらず一定量の直近コンテキストが保持される。
[compaction]
compact_threshold = 80000 # ターンの合間 (proactive)
compact_request_threshold = 90000 # リクエストの合間 (safety net)
retained_tokens = 8000 # 直近保護トークン数 (Prune 済みで計測)
auto_read_budget = 8000 # compact worker の mark_read_required 合計上限
compact_worker_max_input_tokens = 50000 # compact worker 自身の累計入力トークン上限
Auto-Read とリファレンス
2段階のファイル参照:
- Read (
mark_read_required): 全文/範囲指定でコンテキストに注入 - Reference (
add_reference): 「読んだことがある」とだけ伝える。必要なら再読要求
auto-read の system message:
[Auto-read file: src/main.rs:42-142]
fn main() {
let config = Config::load();
...
}
リファレンスの system message:
[Referenced files — read before compaction, contents not included]
- src/config.rs (read during task setup)
- tests/integration_test.rs (read during test implementation)
Use read_file to access current contents if needed.
auto-read も通常の history 内 system message なので、将来の Prune/Compact 対象になる。
compact worker
要約生成とファイル選定を行う使い捨て Worker。ツールなし・1リクエストの現行実装から、 ツール付きマルチターンに改善する。
ツール
read_file(path, offset?, limit?) — ファイルを読んで判断する
mark_read_required(path, offset?, limit?) — auto-read 対象として指定
add_reference(path) — リファレンスとして追加
write_summary(text) — 構造化要約を出力/上書き(最後の呼び出しが採用)
フロー
- Pod が
tools::Tracker::recent_files(5)で最近触られたファイルを抽出(デフォルトリファレンス) - compact worker にプロンプトとして渡す:
- pruned history(summary only、arguments/reasoning 除去)
- デフォルトリファレンスの一覧
- compact worker が自律的に:
- read_file で各ファイルを読み、必要性を判断
- mark_read_required / add_reference で指定
- write_summary で構造化要約を出力(呼び直し可)
- ターン終了時に write_summary 未呼び出し or read_required 空(かつファイル操作履歴がある場合)→ 追加プロンプトで促す
構造化要約の要件
目的: auto-read でコードは別途載せるので、要約は意思決定・コンテキスト・方向性の記録に特化する。
含めるべき内容:
- 何を、なぜやったか — 意思決定の記録。具体的な型名・関数名で言及
- ユーザーの指示・フィードバックの原文 — ニュアンス保持。重要なもののみ
- 発生した問題と解決策 — 同じ轍を踏まない
- 今どこにいて次に何をするか — compact 前後の一貫性
含めないもの:
- コードの全文(auto-read が担う)
- 変更の diff(git がある)
- 中間のやりとりの詳細(最終結論だけ)
要約フォーマット(5セクション、1000-2000 トークン目安)
## Completed Tasks
### (タスク名)
- 完了した作業(具体的な型名・ファイル名で)
- 注意点 / 発覚した事実
## Active Task
### (タスク名)
- 目標
- 現状(何が済んで何が未着手か)
- 次のステップ
## Key Decisions
- (判断内容) — (理由)
## User Directives
- 「(ユーザー発言の原文)」 — 重要な指示・フィードバックのみ
## Current Work
(直前に何をしていたか。2-3行)
セッション中に複数タスクが切り替わっている前提で設計。 完了タスクは簡潔に(注意点・事実のみ)、進行中タスクは十分な詳細で。
設計の背景
Claude Code からの知見
Claude Code の compaction 実装(docs/ref/claude-code-compaction.md)を参考に、以下を取り入れた:
- 条件付き Prune (
min_savings): KV キャッシュ無効化コストとのトレードオフ。clear_at_leastパターン - サーキットブレーカー: 連続失敗の無限ループ防止
- 2段階のファイル参照 (Read vs Referenced): Claude Code は compact 後に「Referenced file」(内容省略)と「Read」(内容保持)を区別する。サイズと必要性で判断
- 要約の具体性: 型名・関数名・ユーザー発言原文を保持。抽象的な要約は役に立たない
設計原則との対応
- Prune は Worker 層の拡張。新しい trait は不要(設計原則3)
- Compact は Pod 層の制御。Worker は Yield を知るが Compact は知らない
- CompactInterceptor は Decorator パターンで HookInterceptor をラップ。既存の Hook 機構を壊さない