yoi/tickets/compact-improvements.md

419 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Compact の改善
## 背景
`Pod::compact()` とその周辺機構は実装済み。
要約品質、保護単位、compact 後のコンテキスト構築に改善が必要。
## 前提(完了済み)
以下は完了済み。git 履歴参照。
- **usage-history** — session-store に `UsageRecord` を積む基盤(`101679d usageデータの永続化実装`
- **token-counter** — Usage 履歴ベースのトークン会計。`Pod::total_tokens()` / `Pod::split_for_retained(n)` として公開済み(`a89c448 token-counter実装`
- **tracker** — `tools::Tracker``recent_files(n)` 実装済み(`6f2362e ToolsのTracker実装`
---
## 要件
### R1: 一貫した振る舞い
- システムプロンプトは不変
- compact 前後でユーザーが違和感を覚えない
- 「何を知っていて何を忘れたか」が自然であること
### R2: 直近の記憶の確実性
- 直近 N トークン分の会話をそのまま保持Prune 済み = summary only の状態で計測)
- **トークン数ベース** で保護量を決める(ターン単位ではない)
- 自走エージェントは1ターン内で多数のリクエストを回す
- ターン単位だと保護量がターン長に依存してしまう
### R3: Auto-Read + リファレンス
- compact 後の最初のターンで、タスク遂行に必要なファイルが既に読まれている
- 2段階: **Read**(全文/範囲をコンテキストに注入)と **Reference**(「読んだことがある」とだけ伝える)
- compact worker が「続行に必要なファイル」を判断して指定する
### R4: マルチタスク対応
- セッション中に一貫した課題に取り組んでいないものとする
- **完了タスク**: 簡潔に。注意点・発覚した事実だけ
- **進行中タスク**: サマリ + 現状 + 次のステップを十分に
---
## 用語の定義(重要 — 混乱防止のため明記)
- **run = turn**: 同じ概念を指す。1 ユーザープロンプト → 完了までの単位
- **リクエスト**: 1 run/turn 内で投げる個別の LLM 呼び出し。ツール使用で 1 turn に複数リクエストが発生する
- **リクエストの合間** (between requests): 1 turn 内、次の LLM リクエストを投げる前の地点。`CompactInterceptor::pre_llm_request` で観測される
- **ターンの合間** (between turns): turn が完了して次の turn を待つ状態。`Controller::try_post_run_compact` で観測される
この 2 つを区別することに意味がある:
- **ターンの合間**は自然なタスクの区切り。次の turn に入る前に **先を見越して早めに** compact すべき
- **リクエストの合間**は turn 内部の中継点。通常は proactive な必要はなく、暴走的な膨張を拾う **safety net** として **遅めに** 発動すれば十分
---
## 閾値の修正(重要)
現状の実装は:
1. 閾値の大小関係が意図と逆
2. `turn_threshold` が pre_llm_request 側で使われていて命名がミスリード
3. もう片方を `turn_threshold * 9 / 8` で導出しているが、9/8 に根拠がない
これらをまとめて修正する。値入れ替え + リネーム + マニフェストで両閾値を個別指定。
### 正しい方針
| チェックポイント | 変数名 (コード) | マニフェスト | 役割 |
|----------------|---------------|------------|------|
| `Controller::try_post_run_compact` (ターンの合間) | `post_run_threshold` | `compact_threshold` | proactive (小) |
| `CompactInterceptor::pre_llm_request` (リクエストの合間) | `request_threshold` | `compact_request_threshold` | safety net (大) |
両方とも manifest で個別指定する。導出はしない。
```toml
[compaction]
compact_threshold = 80000 # ターンの合間, proactive
compact_request_threshold = 90000 # リクエストの合間, safety net
```
想定: `compact_threshold < compact_request_threshold`。逆転していてもエラーにはしないが、
warn を出す。両方 None なら compact 無効(今まで通り)。片方だけ None なら...
**片方だけ指定されたときの挙動**:
- `compact_threshold` のみ設定 → `compact_request_threshold` は無効 (リクエスト間チェック無し)
- `compact_request_threshold` のみ設定 → `compact_threshold` は無効 (post_run チェック無し)
- 両方設定 → 両方有効
`CompactState` 内部では `Option<u64>` 2 本持ち。`exceeds_*` メソッドは `Option``None` なら常に `false`
### 占有量ソースの統合(重要)
現在 `CompactState::last_input_tokens: AtomicU64``on_usage` callback から
更新され、閾値判定に使われている。これは session-store の `UsageRecord`
履歴usage-history で導入済み)と**情報源が二重化**している状態。
本チケットで両者を統合する。**`Pod::total_tokens()`token-counter で導入済み)を
単一の情報源とし、`last_input_tokens` 経路を撤去する**:
- `CompactState` から `last_input_tokens: AtomicU64` フィールドを削除
- `CompactState::update_input_tokens` メソッドを削除
- `Pod::ensure_interceptor_installed` の on_usage callback から
`state_for_usage.update_input_tokens(tokens)` の行を削除
`tracker_for_usage.record_usage(event)` だけが残る)
- 閾値判定 (`exceeds_request` / `exceeds_post_run`) は `Pod::total_tokens()`
戻り値を見る形に変える
- これにより「実測値の単一履歴 → `Pod::total_tokens()` → 閾値判定」と一直線になる
Anthropic のキャッシュヒット時に占有量を取りこぼす旧バグも、このパスを
廃止することで自動的に解消する(`UsageRecord.input_total_tokens` は
scheme 層で占有量に正規化済み)。
### 影響箇所
- **`crates/manifest/src/lib.rs`**
- `CompactionConfig``compact_request_threshold: Option<u64>` フィールドを追加
- デフォルトは `None`
- テスト更新 (両閾値が読めること)
- **`crates/pod/src/compact_state.rs`**
- `last_input_tokens: AtomicU64` フィールドを **削除**(情報源を usage_history に一本化)
- `update_input_tokens` / `last_input_tokens` メソッドも削除
- `turn_threshold` フィールドを `request_threshold: Option<u64>` にリネーム + `Option`
- `post_run_threshold: u64``Option<u64>` に変更
- コンストラクタシグネチャ変更:
```rust
// Before
pub fn new(turn_threshold: u64, retained_turns: usize) -> Self
// After
pub fn new(
post_run_threshold: Option<u64>,
request_threshold: Option<u64>,
retained_turns: usize,
) -> Self
```
- `exceeds_turn()``exceeds_request()` にリネーム。閾値超過判定は
呼び出し側で現在の占有量を渡す形に変えるCompactState は閾値しか持たない):
```rust
pub(crate) fn exceeds_request(&self, current_tokens: u64) -> bool {
self.request_threshold
.map(|t| current_tokens > t)
.unwrap_or(false)
}
```
呼び出し元 (`compact_interceptor.rs` / `controller.rs`) は `Pod::total_tokens()`
から現在の占有量を取って渡す
- `exceeds_post_run()` も同様に Option 対応
- `turn_threshold()` getter → `request_threshold()`、戻り値は `Option<u64>`
- ドックコメントを「proactive = post_run」「safety net = request」で書き直し
- テスト: 両方設定/片方だけ/両方 None の 3 ケース
- **`crates/pod/src/pod.rs`** (上記の compact_state 変更に伴って)
- `ensure_interceptor_installed` の on_usage callback から
`state_for_usage.update_input_tokens(tokens)` の行を削除。
`tracker_for_usage.record_usage(event)` だけが残る
- **`crates/pod/src/compact_interceptor.rs`**
- `exceeds_turn()` 呼び出しを `exceeds_request()`
- ログメッセージ "Between-turns ..." → "Between-requests ..."
- コメント "Step 2: Check between-turns compaction threshold" → "Step 2: Check between-requests compaction threshold (safety net)"
- **`crates/pod/src/pod.rs`**
- `ensure_interceptor_installed``compact_threshold` + `compact_request_threshold` の両方を manifest から読み、`CompactState::new` に渡す
- wrap 条件: 両方 None なら CompactInterceptor を挟まない (+ Controller の post_run チェックも実質無効)。片方でも Some なら挟む
- Disjoint チェックで `post_run_threshold > request_threshold` の場合 warn ログ
- **`docs/compaction.md`**
- TOML 例に `compact_request_threshold` を追加
- トリガーセクションから「9/8 で導出」の記述を削除、個別指定である旨に修正
---
## compact 後の history 構造
全て system message`Item::Message { role: System }`)として注入。
```
[system prompt] ← 不変 (R1)
[system: 構造化要約] ← R4: compact worker の出力
[system: auto-read ファイル群] ← R3: read_required の結果
[system: リファレンス一覧] ← R3: reference の結果
[直近 N トークン分の生の会話] ← R2: pruned 状態で保持
```
system message で統一する理由:
- LLM に「システムから提供された前提情報」として認識させる
- fake ユーザーメッセージや fake ToolCall を作らない
- 要約もファイルも同じ role で自然に並ぶ
### 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.
```
---
## R2: トークンベースの保護
現状の `retained_turns``retained_tokens` に変更。
```
history (全て pruned 済み = summary only):
[...古い部分...] [...直近 N トークン分...]
↓ ↓
要約対象 そのまま新 history に載せる
```
- Prune 済みの history に対して `Pod::split_for_retained(N)`token-counter で
導入済み)で cut 位置を求める
- 計算は session-store の `UsageRecord` 履歴 (実測値) を逆算ソースに使う
- ターン境界は無視。アイテム単位で切る
```toml
[compaction]
compact_threshold = 80000
retained_tokens = 8000 # ← retained_turns から変更
```
---
## R3: Auto-Read + リファレンス
### デフォルトリファレンスの抽出
`tools::Tracker`(実装済み)が Read/Write/Edit で触られたファイルを LRU で
保持している。Compact 時は `self.tracker.recent_files(5)` で先頭 5 件を
compact worker のデフォルトリファレンスとして渡す。
### compact worker のツール
```
read_file(path, offset?, limit?) — ファイルを読んで判断するため
mark_read_required(path, offset?, limit?) — auto-read 対象(内容をコンテキストに載せる)
add_reference(path) — リファレンス追加(内容は載せない)
write_summary(text) — 構造化要約を出力/上書き(上書き可)
```
`write_summary` は**上書き可**。マルチターンで「下書き → 追加 read → 書き直し」の順序が自然に動く。
最終的に直近の呼び出しが採用される。ガードは「一度も呼ばれていない」時のみ。
### フロー
1. Pod が `Tracker::recent_files(5)` で最近触られたファイルを抽出(デフォルトリファレンス)
2. compact worker のプロンプトに含める:
```
以下のファイルがリファレンスとして指定されています。
全て読んで、タスク続行に必要なものを mark_read_required で指定してください。
リファレンスを追加したい場合は add_reference で追加できます。
```
3. compact worker が read_file で全ファイルを読み、判断:
- 必要なファイル → `mark_read_required(path, offset?, limit?)`
- 不要だがコンテキストとして有用 → リファレンスのまま残す
- 追加のリファレンス → `add_reference(path)`
4. `write_summary` で構造化要約を出力(最後のが採用される)
5. ターン終了時に summary が一度も書かれていない or read_required が空(かつファイル操作履歴がある場合)→ 追加プロンプトで促す
### Auto-Read の Budget 管理
compact worker が `mark_read_required` を無制限に呼ぶとコンテキストが膨張する。
共有 budget で制御:
```toml
[compaction]
auto_read_budget = 8000 # 合計トークン上限
```
- `mark_read_required` のツール結果で残量を返す:
`"Marked. Budget: 4200/8000 tokens remaining"`
- 50% 以下になったら次のツール結果に system reminder を append:
`"Budget half consumed. Consider calling write_summary soon."`
- 100% 超過で Err:
`"Error: auto-read budget exhausted (8000 tokens). Remove an existing mark or use add_reference instead."`
- compact worker が判断して自分で調整できるErr は即中断ではない)
### compact worker の暴走抑止
Turn/request 数ではなく、compact worker の累計入力トークンで上限を設ける:
```toml
[compaction]
compact_worker_max_input_tokens = 50000
```
超えたら compact worker を強制終了。`CompactState::record_compact_failure()` 経由で
サーキットブレーカーの自然な経路に乗る。
---
## R4: 要約の内容と品質
### 出力方法
compact worker が `write_summary(text)` ツールで出力する(上書き可)。
最後のテキスト出力ではなくツールにする理由:
- マルチターンで read_file → 判断 → 要約の順序が自由
- 要約を書いた後にさらにリファレンスを追加できる
- 「要約を書いていない」のガードが mark_read_required と同じパターンで検出可能
### 含めるべき内容
コードスニペットは auto-read に任せる。要約に求めるのは:
1. **何を、なぜやったか** — 意思決定の記録。具体的な型名・関数名で言及
2. **ユーザーの指示・フィードバックの原文** — ニュアンス保持。重要なもののみ
3. **発生した問題と解決策** — 同じ轍を踏まない
4. **今どこにいて次に何をするか** — compact 前後の一貫性 (R1)
含めないもの:
- コードの全文auto-read が担う)
- 変更の diffgit がある)
- 中間のやりとりの詳細(最終結論だけ)
### フォーマット5セクション、1000-2000 トークン目安)
```
## Completed Tasks
### (タスク名)
- 完了した作業(具体的な型名・ファイル名で)
- 注意点 / 発覚した事実
### (タスク名)
- ...
## Active Task
### (タスク名)
- 目標
- 現状(何が済んで何が未着手か)
- 次のステップ
## Key Decisions
- (判断内容) — (理由)
- ...
## User Directives
- 「(ユーザー発言の原文)」 — 重要な指示・フィードバックのみ
- ...
## Current Work
直前に何をしていたか。2-3行
```
各セクションの目安量:
- Completed Tasks: 各タスク 2-3 行 × タスク数
- Active Task: 5-10 行
- Key Decisions: 各 1-2 行
- User Directives: 重要な発言のみ原文引用
- Current Work: 2-3 行
### 要約の入力
pruned history から:
- ToolResult は summary のみcontent 除去)
- ToolCall は名前のみarguments 除去)
- Reasoning は除去
---
## 挙動の未決定事項
### Yield のタイミング精度
現状 `pre_llm_request`(リクエストの合間)でのみチェック。
1 turn 内でツール呼び出しが多く途中でコンテキストが膨らむケースは次のリクエストまで待つ。
検討: `post_tool_call` でもチェックする?
### 閾値の推奨値
- `compact_threshold` (post_run, proactive): モデルのコンテキスト上限の 70-80% あたりが目安
- `compact_request_threshold` (request, safety net): `compact_threshold` より少し上、85-95% あたり
両方 manifest で個別指定する(導出はしない)。要調整の余地あり。
### Prune と Compact の相互作用
Prune はリクエストコンテキストのみ操作。閾値判定は usage_history の最新
測定値(前回の LLM レスポンス時点の占有量を見るので、Prune の効果は
次回 LLM call まで反映されない。保守的compact しすぎる方向)で実害は小さい。
### compact 中のクライアント通知
Protocol チケットの `CompactStart`/`CompactDone` で対応。
### 復元時の挙動
`Outcome::Yielded` で記録されたセッションは `last_run_interrupted = true` で復元。
compact 後の新セッションが存在する場合、どちらを restore するかは呼び出し側の責任。
`compacted_from` で辿れる。
---
## 実装順序
前提usage-history / token-counter / trackerは完了済み。
1. **閾値の修正 + リネーム + 個別指定化 + 占有量ソース統合** — manifest に `compact_request_threshold` 追加、`compact_state.rs` の 2 閾値を `Option<u64>` 化、`turn_threshold` → `request_threshold` リネーム、`exceeds_turn()` → `exceeds_request()`。`last_input_tokens` 撤去、閾値判定は `Pod::total_tokens()` 経由に切替。compact_state.rs / compact_interceptor.rs / pod.rs / manifest / テスト / docs 更新
2. **要約入力の削減**`build_summary_prompt` から content/arguments/reasoning を除去
3. **retained_tokens 化** — retained_turns → retained_tokens に変更。マニフェスト設定追加
4. **compact worker のツール化** — read_file + mark_read_required + add_reference + write_summary (上書き可)
5. **Auto-Read + リファレンス** — デフォルト5ファイル抽出 (`Tracker::recent_files` から)、compact worker による選定、system message での注入
6. **Auto-Read Budget**`mark_read_required` のトークン会計、残量通知、超過エラー
7. **compact worker の累計入力トークン制限**`compact_worker_max_input_tokens`
8. **要約フォーマット** — タスク分類の要約プロンプト調整
9. **ガード** — write_summary 未呼び出し or mark_read_required 空時の追加プロンプト