From 22fe502d71bc25c5393d10b07bdd70d68c1ba181 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 13 Apr 2026 05:58:33 +0900 Subject: [PATCH] =?UTF-8?q?TODO=E3=83=BBTicket=E3=81=AE=E3=82=A2=E3=83=83?= =?UTF-8?q?=E3=83=97=E3=83=87=E3=83=BC=E3=83=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 28 ++++- CLAUDE.md | 28 ++++- TODO.md | 21 +--- tickets/compact-improvements.md | 14 ++- tickets/token-counter.md | 153 +++++++++++++++++++------ tickets/usage-history.md | 197 ++++++++++++++++++++++++++++++++ 6 files changed, 375 insertions(+), 66 deletions(-) create mode 100644 tickets/usage-history.md diff --git a/AGENTS.md b/AGENTS.md index 374932f7..321a0b74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,6 +3,30 @@ --- `TODO.md`、`tickets/`はgitで管理されていて、時系列の管理はgitを参照して把握すること。 -Ticketは完了したら削除され、`TODO.md`はチェックを付けて積まれていく。 + +### TODO.md + +- 1チケット = 1行。未完了のみ記載し、完了したら行ごと削除する(履歴はgitで追える) +- ネストは同一領域のグルーピング(表示用)にのみ使う。実装上の依存関係はネストで表現しない +- 完了した子は削除し、親は未完了の子がある限り残す。最後の子が完了したら親ごと削除 +- Ticketを追加する際は、合わせてTODOも書くこと + +### Ticket の粒度 + +- 1チケット = 完了時点で、実装が仕様又は機能として説明できる粒度。 +- 作成時、背景や要件を前提として書き、実装の方針やコードの詳細は不必要に増やさない。 +- チケット内のステップ(Phase 1, 2, ...)は実装順序であり、TODO等、外に出さない +- ビルドが通り、その機能に限り,まだ動作できないと明示出来ている場合を除いて全体を通して動作させられる状態である必要がある。 + +### Ticket のライフサイクル + +gitがタイムラインの単一の情報源。ファイル操作とcommitで状態遷移を表現する。 + +a. 作成: `tickets/foo.md` を作成してcommit +b. 詳細化や前提の変化: `tickets/foo.md` を更新してcommit +c. レビュー: `tickets/foo.md` にレビュー状態を追記 + `tickets/foo.review.md` を作成してcommit +d. 完了: `tickets/foo.md` と `tickets/foo.review.md` を両方削除してcommit + TODO.mdのリンクは完了後に切れるが、そのリンクを元にgitで消されたファイルを読み、内容を把握できる。 -Ticketを追加する際は、合わせてTODOも書くこと。 +`.review.md` にはレビューの指摘事項と判断結果を記載する。 +レビューはdiffの確認だけでなく、チケットはどのような前提・要件であり、それが達成されたかの確認まで含めて行う。 diff --git a/CLAUDE.md b/CLAUDE.md index 374932f7..321a0b74 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,6 +3,30 @@ --- `TODO.md`、`tickets/`はgitで管理されていて、時系列の管理はgitを参照して把握すること。 -Ticketは完了したら削除され、`TODO.md`はチェックを付けて積まれていく。 + +### TODO.md + +- 1チケット = 1行。未完了のみ記載し、完了したら行ごと削除する(履歴はgitで追える) +- ネストは同一領域のグルーピング(表示用)にのみ使う。実装上の依存関係はネストで表現しない +- 完了した子は削除し、親は未完了の子がある限り残す。最後の子が完了したら親ごと削除 +- Ticketを追加する際は、合わせてTODOも書くこと + +### Ticket の粒度 + +- 1チケット = 完了時点で、実装が仕様又は機能として説明できる粒度。 +- 作成時、背景や要件を前提として書き、実装の方針やコードの詳細は不必要に増やさない。 +- チケット内のステップ(Phase 1, 2, ...)は実装順序であり、TODO等、外に出さない +- ビルドが通り、その機能に限り,まだ動作できないと明示出来ている場合を除いて全体を通して動作させられる状態である必要がある。 + +### Ticket のライフサイクル + +gitがタイムラインの単一の情報源。ファイル操作とcommitで状態遷移を表現する。 + +a. 作成: `tickets/foo.md` を作成してcommit +b. 詳細化や前提の変化: `tickets/foo.md` を更新してcommit +c. レビュー: `tickets/foo.md` にレビュー状態を追記 + `tickets/foo.review.md` を作成してcommit +d. 完了: `tickets/foo.md` と `tickets/foo.review.md` を両方削除してcommit + TODO.mdのリンクは完了後に切れるが、そのリンクを元にgitで消されたファイルを読み、内容を把握できる。 -Ticketを追加する際は、合わせてTODOも書くこと。 +`.review.md` にはレビューの指摘事項と判断結果を記載する。 +レビューはdiffの確認だけでなく、チケットはどのような前提・要件であり、それが達成されたかの確認まで含めて行う。 diff --git a/TODO.md b/TODO.md index d246b777..c74d7c17 100644 --- a/TODO.md +++ b/TODO.md @@ -1,27 +1,10 @@ -- [x] 永続化データ構造の制定 - [ ] テスト設計 → [tickets/test-design.md](tickets/test-design.md) -- [x] ツール出力の遅延読み込み設計 (ToolOutput / BlobStore / auto_summarize) -- [x] ToolOutput 再設計: summary + content 構造化、BlobStore/inspect 削除 → [tickets/tool-output-design.md](tickets/tool-output-design.md) - [ ] ツール設計 - - [x] ツールの動的追加/削除 → [tickets/tool-dynamic-registry.md](tickets/tool-dynamic-registry.md) - - [x] run() 自動ロックとファクトリ遅延初期化 → [tickets/worker-auto-lock.md](tickets/worker-auto-lock.md) - - [x] 組み込みツール実装 (tools クレート、Bash 除く) → [tickets/builtin-tools.md](tickets/builtin-tools.md) - [ ] Bash ツール (Permission 層と統合) → [tickets/bash-tool.md](tickets/bash-tool.md) - [ ] Scope の再設計 (pwd + writable、必須化) → [tickets/scope-redesign.md](tickets/scope-redesign.md) -- [x] inspect ツール実装 -- [x] max_turns: マニフェストによるターン数制限 -- [x] pod バイナリエントリポイント -- [x] セッションエントリのハッシュチェーン -- [x] Subscriber → クロージャ API 移行 -- [x] JSONL ストリーム変換ユーティリティ (protocol::stream) -- [x] Hook モジュールの llm-worker からの除去 → [tickets/remove-hook-module.md](tickets/remove-hook-module.md) -- [x] api_key_file: ファイルパスによるAPIキー解決 → [tickets/api-key-file.md](tickets/api-key-file.md) -- [x] コンテキスト圧縮 (Prune + Compact) → [tickets/context-compaction.md](tickets/context-compaction.md) -- [ ] LlmClient へ Tokenizer の導入 → [tickets/token-counter.md](tickets/token-counter.md) -- [x] Tracker: ReadTracker リネーム + recent_files 追加 → [tickets/tracker.md](tickets/tracker.md) +- [ ] Usage 履歴の永続化 (session-store に LlmUsage entry 追加) → [tickets/usage-history.md](tickets/usage-history.md) +- [ ] トークン会計 (Usage 履歴ベースの retained/savings 計算) → [tickets/token-counter.md](tickets/token-counter.md) - [ ] Compact の改善(要約品質 + 挙動詳細) → [tickets/compact-improvements.md](tickets/compact-improvements.md) - [ ] Protocol の設計 → [tickets/protocol-design.md](tickets/protocol-design.md) -- [x] Protocol: request-response パターン (GetHistory等) → [tickets/request-response-protocol.md](tickets/request-response-protocol.md) - [ ] パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md) -- [x] session-store: persistence クレートの再構成(wrap廃止、リネーム) → [tickets/session-store-extraction.md](tickets/session-store-extraction.md) - [ ] UI用トークン情報の記録(run stats の永続化、session-store 後) diff --git a/tickets/compact-improvements.md b/tickets/compact-improvements.md index bc0b9e1e..81b3c4e8 100644 --- a/tickets/compact-improvements.md +++ b/tickets/compact-improvements.md @@ -7,7 +7,8 @@ ## 前提チケット -- [token-counter.md](token-counter.md) — LlmClient に Tokenizer 導入。retained_tokens / auto-read budget がこれに依存 +- [usage-history.md](usage-history.md) — session-store に LLM Usage を積む基盤 +- [token-counter.md](token-counter.md) — Usage 履歴ベースのトークン会計 API。retained_tokens / auto-read budget がこれに依存 - [tracker.md](tracker.md) — `ReadTracker` → `Tracker` リネーム + `recent_files(n)` 追加。デフォルトリファレンスがこれに依存 --- @@ -183,8 +184,8 @@ history (全て pruned 済み = summary only): 要約対象 そのまま新 history に載せる ``` -- Prune 済みの history に対して `LlmClient::tokenizer()` でトークン数を推定 -- 末尾から逆順に数えて N トークン分の位置で切る +- Prune 済みの history に対して `Session::split_for_retained(N)` で cut 位置を求める +- 計算は session-store の Usage 履歴 (実測値) を逆算ソースに使う - ターン境界は無視。アイテム単位で切る ```toml @@ -193,7 +194,7 @@ compact_threshold = 80000 retained_tokens = 8000 # ← retained_turns から変更 ``` -token-counter チケットが前提。 +token-counter チケット(および前提の usage-history)が前提。 --- @@ -253,7 +254,7 @@ auto_read_budget = 8000 # 合計トークン上限 `"Error: auto-read budget exhausted (8000 tokens). Remove an existing mark or use add_reference instead."` - compact worker が判断して自分で調整できる(Err は即中断ではない) -token-counter チケットが前提(budget の計測に `estimate_text` が要る)。 +token-counter チケットが前提(budget の計測にトークン会計 API が要る)。 ### compact worker の暴走抑止 @@ -372,7 +373,8 @@ compact 後の新セッションが存在する場合、どちらを restore す ## 実装順序 -0. **[前提] token-counter** — LlmClient に Tokenizer +0. **[前提] usage-history** — session-store に LLM Usage 蓄積 +0. **[前提] token-counter** — Usage 履歴ベースのトークン会計 API 0. **[前提] tracker** — `ReadTracker` → `Tracker` リネーム + `recent_files` 追加 + Pod 接続 1. **閾値の修正 + リネーム + 個別指定化** — manifest に `compact_request_threshold` 追加、`compact_state.rs` の 2 閾値を `Option` 化、`turn_threshold` → `request_threshold` リネーム、`exceeds_turn()` → `exceeds_request()`。compact_state.rs / compact_interceptor.rs / pod.rs / manifest / テスト / docs 更新 2. **要約入力の削減** — `build_summary_prompt` から content/arguments/reasoning を除去 diff --git a/tickets/token-counter.md b/tickets/token-counter.md index 5f74d786..a668c430 100644 --- a/tickets/token-counter.md +++ b/tickets/token-counter.md @@ -1,67 +1,146 @@ -# LlmClient へ Tokenizer の導入 +# トークン会計 (Usage 履歴ベース) ## 背景 -現状、トークン数の推定は `len / 4` の荒い近似でしかできていない。 -Compact 改善で以下が全て正確なトークン数に依存するため、LlmClient 層で -トークナイザを提供する仕組みが必要になる。 +Compact / Prune の挙動改善に「**履歴上の任意位置のトークン数**」と +「**ある変更でどれだけトークンが浮くか**」を答えられる仕組みが要る。 + +ローカル近似(`len/4` や BPE テーブル)はモデル/言語/ツール overhead の +誤差が大きく、Anthropic に至ってはオフラインで正確に数える手段が無い。 + +一方、LLM レスポンスの `Usage` は **送信した history prefix に対して +プロバイダが実測したトークン数** であり、これが手元にある最も正確な情報源。 + +リクエスト毎にスナップショットを蓄積すれば、ターンより細かい粒度で +履歴の任意 suffix のトークン数を実測ベースで逆算できる。 ## 動機 -正確なトークン数が必要になる箇所: +正確なトークン数(推定でも実測由来)が要る箇所: -- Prune の `min_savings` 判定(節約見込みの事前推定) -- Compact 後の `retained_tokens` 切り出し(直近 N トークンの保護) -- Compact worker の auto-read budget 判定 -- Protocol の UI 向けトークン表示(将来) +- **Compact の retained_tokens 切り出し** — 末尾から N トークン残す cut 位置を決める +- **Prune の `min_savings` 判定** — 「この content を捨てたら何トークン浮くか」を見積もる +- **Compact worker の auto-read budget 判定** — `mark_read_required` の累計 +- **UI 向けトークン表示**(将来) -`CompactState::last_input_tokens` は LLM レスポンスから得られる実測値なので -これには影響しない(閾値比較だけなら Tokenizer なしで回る)。ただし -retained_tokens の切り出しは事前にローカルで計算する必要がある。 +ターン境界での切り出しでは粒度が粗すぎる。長く自走するエージェントは +1ターン内で多数のリクエストを回し、ターン長が大きくバラつくため。 + +## 前提 + +- [usage-history.md](usage-history.md) — session-store に `LogEntry::LlmUsage` + を追加し、Worker からリクエスト送信時の prefix と実測値を組で記録する基盤。 + 本チケットはその履歴を消費する側。 ## 方針 -`LlmClient` trait に同期的な `Tokenizer` 取得口を追加する。非同期 API は使わない -(Prune/Compact の判定フローを async にしたくない)。 +ローカルなトークナイザは持たない。プロバイダ実測値の履歴 (`UsageRecord` 列) +と現在の history items から、ピュアな計算で答えを返す。 + +API は **session 概念を持つ型のメソッド** として生やす。両方を所有する +オーナーが呼ぶ形になるので引数はゼロ。具体的な配置先は実装時に確定する +(候補: `RestoredState` / Worker 内の history 所有者 / 薄い view 型)。 ```rust -pub trait Tokenizer: Send + Sync { - fn estimate_text(&self, s: &str) -> u64; - fn estimate_items(&self, items: &[Item]) -> u64; +impl Session { + /// 現在の history 全体の推定トークン数。 + /// 最後の measurement + その後追加された未測定分の按分。 + pub fn total_tokens(&self) -> TokenEstimate; + + /// 末尾から `retained_tokens` 以上を残すための分割位置 (history index)。 + /// `items[..cut.index]` が捨てる/要約される側、`items[cut.index..]` が残る側。 + pub fn split_for_retained(&self, retained_tokens: u64) -> SplitPoint; + + /// 指定範囲の items を drop した場合の推定節約トークン数。 + pub fn savings_for_drop(&self, range: Range) -> TokenEstimate; } -pub trait LlmClient { - // 既存メソッド... - fn tokenizer(&self) -> Arc; +pub struct TokenEstimate { + pub tokens: u64, + pub source: EstimateSource, +} + +pub struct SplitPoint { + pub index: usize, + pub source: EstimateSource, +} + +/// 推定の出どころ。呼び出し側が「概算である」ことを認識して扱えるよう明示する。 +pub enum EstimateSource { + /// measurement の境界とちょうど一致(実測そのもの) + Measured, + /// 連続する 2 measurement の間をバイト按分で計算 + Interpolated, + /// 最後の measurement より新しい区間 → 最後の rate で外挿 + Extrapolated, + /// measurement ゼロ件 → バイト数のみのフォールバック + NoData, } ``` -- provider ごとに実装。精度は近似で十分(±10% 程度) -- OpenAI/Anthropic 系は `tiktoken-rs` + BPE テーブルベースの近似 -- Gemini も近似で対応。厳密値が要る場面があれば後から `count_tokens` API 呼び出しに置き換え可 -- `estimate_items` は `Item` の variant を舐めて text/tool name/arguments を足し込む単純実装 +呼び出し側: +```rust +let cut = session.split_for_retained(8_000); +let saved = session.savings_for_drop(0..cut.index); +if saved.tokens >= min_savings { + // prune を実行 +} +``` ## 設計ポイント -- **同期 API**: Prune hook や Compact 判定の中で使う。`async fn` を増やさない -- **Arc 返却**: Worker/Pod で使い回せるよう共有参照 -- **近似で十分**: 正確性より呼び出しコストの低さを優先。実測値 (`last_input_tokens`) との二重化で補正される -- **Client ごとの実装**: provider 固有のトークナイザ差異はここで吸収 +- **状態を持たない**: 計算は所有 history と所有 measurements を見るだけの pure 関数。 + trait もインスタンスも tokenizer も要らない +- **概算であることを返り値で明示**: `EstimateSource` で呼び出し側が + measurement 直上 / 按分 / 外挿 / 履歴無しを区別できる。課金判断には使えない +- **provider 非依存**: tiktoken やプロバイダ別実装は一切不要。 + 実測値に provider/model/言語/ツール overhead が全て込み +- **キャッシュヒットの扱い**: `cache_read_input_tokens` は「コンテキスト占有量」 + には含めるが「実コスト」とは区別する。Compact/Prune の判定は占有量基準 + (= raw input_tokens、cache_read 込みの total) + +## 計算アルゴリズム + +`measurements: &[UsageRecord]` (`(history_len_at_send, input_tokens)` の昇順列)と +`history: &[Item]` から: + +### `total_tokens` +1. measurement が無い → `NoData`、history のバイト数で粗い概算 +2. 最新 measurement の `tokens` を起点に、`history.len() > last.history_len` の + 差分があれば最終 rate (tokens / total_bytes) で按分して足す → `Extrapolated` +3. ぴったり history が一致 → `Measured` + +### `split_for_retained(retained)` +1. measurements を末尾から走査 +2. `current_total - measurement.tokens >= retained` を満たす最小の measurement を見つける +3. 一致する measurement あり → `index = measurement.history_len`, `Measured` +4. 2つの measurement の間に境界がある → 区間内のアイテムをバイト按分して `Interpolated` +5. 最後の measurement より新しい区間で境界 → 最終 rate で外挿、`Extrapolated` +6. measurements 不足 → `NoData` でバイト按分フォールバック + +### `savings_for_drop(range)` +1. range と measurement の境界の包含関係を見て、区間を完全に含む measurement の + 差分を取る → `Measured` or `Interpolated` +2. range が最後の measurement より後ろを含む → 外挿で `Extrapolated` +3. measurements 無し → バイト数のみ、`NoData` ## 実装対象 -- `llm-worker/src/llm_client/tokenizer.rs` (新規) — `Tokenizer` trait 定義 -- `llm-worker/src/llm_client/types.rs` or `LlmClient` trait — `fn tokenizer(&self) -> Arc` -- provider 実装: - - `providers/anthropic.rs` - - `providers/openai.rs` - - `providers/gemini.rs` -- 依存追加: `tiktoken-rs` (`cargo add` で) +- session-store または最終的な配置先クレート: + - `Session`(または既存の history+measurements 所有者)に + `total_tokens` / `split_for_retained` / `savings_for_drop` を実装 + - `TokenEstimate` / `SplitPoint` / `EstimateSource` 型 + - 単体テスト: measurement 0/1/N 件、ぴったり境界、按分、外挿、prune 後の整合性 +- `crates/llm-worker/src/prune.rs`: + - `estimate_tokens` を削除し、`min_savings` 判定を `Session::savings_for_drop` + 呼び出しに置き換え(呼び出し側で渡す) + - prune の API シグネチャ調整は最小限に ## 依存 -- なし(これ自体が前提チケット) +- [usage-history.md](usage-history.md) — Usage を session-store に積む基盤 ## ブロックする後続 -- [compact-improvements.md](compact-improvements.md) — retained_tokens, auto-read budget が依存 +- [compact-improvements.md](compact-improvements.md) — retained_tokens 化、 + auto-read budget、prune の min_savings 精度向上が依存 diff --git a/tickets/usage-history.md b/tickets/usage-history.md new file mode 100644 index 00000000..34a1932d --- /dev/null +++ b/tickets/usage-history.md @@ -0,0 +1,197 @@ +# Usage 履歴の永続化 + +## 背景 + +LLM レスポンスから得られる Usage は **プロバイダがリクエスト送信時の history prefix +に対して実測したトークン数**であり、手元にある最も正確なトークン情報。 + +現状は `CompactState::last_input_tokens` に最新の input_tokens だけを `AtomicU64` +で上書き保存しており、過去履歴も prefix との対応関係も失われている。 + +これを session-store の append-only ログに積めば、ハッシュチェーンに乗った +tamper-evident な実測値の時系列が得られ、履歴上の任意位置の占有量を逆算できる。 + +## 動機 + +- ターンより細かい粒度で「履歴のここまでが N トークン」と言いたい + (長く自走するエージェントは1ターンが長いため、ターン境界では粗すぎる) +- セッションを restore した直後でも、過去の実測値が手元にある状態にしたい +- Compact / Prune の判断、UI のトークン表示、コスト集計、デバッグ用の + 時系列分析、すべての基盤になる + +## データモデル + +求めているのは **「history のある位置における占有量スナップショット」** であって +LLM 呼び出しの input/output 統計ではない。ただし占有量を実測するための測定値が +そのまま 1 リクエスト = 1 entry の形に乗るので、測定値を素直に保存する。 + +### `LogEntry::LlmUsage` 追加 + +```rust +// crates/session-store/src/session_log.rs + +LlmUsage { + ts: u64, + /// 送信時の history.len()。以下の測定値はこの prefix に対するもの + history_len: usize, + /// history[..history_len] をプロバイダが実測した占有量(プロンプト全長)。 + /// このリクエストで新たに追加したトークン数ではなく、折り返しを想定した prefix 全体。 + /// 各プロバイダの正規化: + /// - Anthropic: input_tokens + cache_read + cache_creation + /// - OpenAI: prompt_tokens + /// - Gemini: promptTokenCount + /// - Ollama: prompt_eval_count + input_total_tokens: u64, + /// 上記のうちキャッシュから読み出された分。料金会計用 + /// - Anthropic: cache_read_input_tokens + /// - OpenAI: prompt_tokens_details.cached_tokens + /// - Gemini: cachedContentTokenCount + /// - Ollama: 0 + cache_read_tokens: u64, + /// 上記のうちこのリクエストでキャッシュに書かれた分。Anthropic のみ非ゼロ + cache_write_tokens: u64, + /// このリクエストで生成された出力トークン数 + output_tokens: u64, +} +``` + +- ハッシュチェーンに乗る (`HashedEntry` の通常 variant として) +- `collect_state` の replay で `RestoredState.usage_history` に積まれる +- 1 リクエスト = 1 entry。Anthropic は message_start と message_delta の2回 Usage + を出すが、llm-worker 側で集約して **完了時の最終値だけ** を pod に渡す + +### `RestoredState` 拡張 + +```rust +pub struct RestoredState { + // ... 既存 + pub usage_history: Vec, +} + +#[derive(Debug, Clone)] +pub struct UsageRecord { + pub history_len: usize, + pub input_total_tokens: u64, + pub cache_read_tokens: u64, + pub cache_write_tokens: u64, + pub output_tokens: u64, +} +``` + +`collect_state` の replay で `LlmUsage` entry を見たら `usage_history` に push。 +他の variant のように `history` を変化させることはない(独立した時系列)。 + +### `save_usage` 関数 + +```rust +// crates/session-store/src/session.rs + +pub async fn save_usage( + store: &impl Store, + session_id: SessionId, + head_hash: &mut Option, + history_len: usize, + input_total_tokens: u64, + cache_read_tokens: u64, + cache_write_tokens: u64, + output_tokens: u64, +) -> Result<(), StoreError>; +``` + +## タイミングと結線 + +**append のタイミングはリクエスト完了時に 1 回**。送信時には storage に触らない。 + +### llm-worker 側 + +- 各プロバイダの scheme で 1 リクエスト内の複数 Usage event(Anthropic の + message_start + message_delta)を集約し、**完了時の最終値だけを 1 つの + `UsageEvent` として外に発火する**。pod 側では暫定値を見ない +- `UsageEvent` 上で provider 別 raw 値(`input_tokens` / `cache_read_input_tokens` + / `cache_creation_input_tokens` / `output_tokens`)はそのまま保持。占有量への + 正規化は consumer 側(save_usage 呼び出し側)で行う + - 動機: llm-worker は raw 値の運搬役に徹し、「プロンプト全長」のような + プロバイダ依存の意味付けは upper layer に集約する + - 正規化ヘルパー(例: `UsageEvent::input_total_tokens()`)を llm-worker に + 生やすかは実装時判断 + +### pod 側 + +- LLM リクエスト送信**直前**に `history.len()` を捕捉して stash + (`Arc>>` などで on_usage callback と共有) +- `on_usage` callback で stash された `history_len` と Usage の最終値を組にして + `save_usage` を呼ぶ +- 既存の `CompactState::update_input_tokens` 経路はそのまま残してよい + (閾値判定は最新値だけで足りる)。`save_usage` はそれと並列に呼ぶ + +結線箇所はおそらく `crates/pod/` 内の既存 `compact_state.rs` の `on_usage` +callback と同じ場所。 + +## 任意位置のトークン数を割り出せること + +このデータがあれば、後段(token-counter)が `history[..M]` の占有量を逆算できる: + +各 `UsageRecord` から得られるデータ点: +1. **送信時点**: `(history_len, input_total_tokens)` — 完全な実測 +2. **応答完了時点**: `(history_len + k, input_total_tokens + output_tokens)` — + k は応答で追加された item 数。次の `LlmUsage` の history_len と log 上の + AssistantItems entry から復元できる + +`history[..M]` のトークン数を求める手順: +- 上記のデータ点を時系列に並べる +- M がデータ点と一致 → 実測値そのまま +- 2つのデータ点の間 → 区間内 items のバイト数で按分 +- 最後のデータ点より新しい → 最後の rate で外挿 + +途中で user input や hook injected items が入った分は、次の LlmUsage 時点で +再測定されてキャリブレートされるので誤差は永久に蓄積しない。 + +詳細アルゴリズムと API は [token-counter.md](token-counter.md) で扱う。 + +## 設計ポイント + +- **最細粒度はリクエスト単位**: provider API がそれ以上細かく返さない。 + ターンより遥かに細かいのでこれで十分 +- **append-only + ハッシュチェーン**: 改ざん検知や fork 検出に乗る +- **collect_state の replay コストはほぼゼロ**: usage_history に push するだけで + history の構築には影響しない +- **既存ログとの互換性**: 古いログには `LlmUsage` entry が存在しないだけ。 + `RestoredState.usage_history` が空になる以外の挙動変化は無い +- **占有量とコストの両立**: input_total と cache 内訳を別フィールドに持つので、 + Compact/Prune 用の占有量も将来のコスト集計も同じ entry から取れる + +## 実装対象 + +- `crates/llm-worker/` + - 各 provider scheme で 1 リクエスト内の複数 Usage event を集約し、 + 完了時に 1 度だけ最終値を発火する仕組み + - Anthropic の `cache_read_input_tokens` / `cache_creation_input_tokens` を + `UsageEvent` 経由で正しく外に出せていることの確認(既に出している) +- `crates/session-store/src/session_log.rs` + - `LogEntry::LlmUsage` variant 追加 + - `UsageRecord` 型追加 + - `RestoredState.usage_history: Vec` 追加 + - `collect_state` で `LlmUsage` を replay +- `crates/session-store/src/session.rs` + - `save_usage` 関数追加 + - `lib.rs` から re-export +- `crates/pod/` + - LLM リクエスト送信直前に `history.len()` を stash する仕組み + (`Arc>>` を on_usage callback と共有) + - `on_usage` callback から `save_usage` 呼び出し + - provider 別の占有量正規化(Anthropic は cache_read + cache_creation を + 足す)をどこに置くか実装時判断 +- テスト + - `LlmUsage` を含むログの round-trip + - 複数 entry を replay して `usage_history` が時系列順に積まれる + - 既存ログ(`LlmUsage` 無し)を読んでも壊れない + - Anthropic の cache hit ありレスポンスで input_total が正しく計算される + +## 依存 + +- なし(前提チケット) + +## ブロックする後続 + +- [token-counter.md](token-counter.md) — この履歴を消費して計算する側 +- [compact-improvements.md](compact-improvements.md) — 上記経由で依存