diff --git a/TODO.md b/TODO.md index d8f9d958..c8657ab3 100644 --- a/TODO.md +++ b/TODO.md @@ -5,7 +5,9 @@ - [ ] ツール設計 - [x] ツールの動的追加/削除 → [tickets/tool-dynamic-registry.md](tickets/tool-dynamic-registry.md) - [x] run() 自動ロックとファクトリ遅延初期化 → [tickets/worker-auto-lock.md](tickets/worker-auto-lock.md) - - [ ] 組み込みツール実装 (tools クレート) → [tickets/builtin-tools.md](tickets/builtin-tools.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 バイナリエントリポイント @@ -16,7 +18,7 @@ - [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) -- [ ] ToolOutput.referenced_files: ツール参照ファイル追跡 → [tickets/tool-output-referenced-files.md](tickets/tool-output-referenced-files.md) +- [ ] Tracker: ReadTracker リネーム + recent_files 追加 → [tickets/tracker.md](tickets/tracker.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) diff --git a/tickets/bash-tool.md b/tickets/bash-tool.md new file mode 100644 index 00000000..4ac7cbc2 --- /dev/null +++ b/tickets/bash-tool.md @@ -0,0 +1,27 @@ +# Bash ツール + +## 背景 + +builtin-tools チケットで Read/Write/Edit/Glob/Grep の5ツールは実装済み。 +Bash ツールは子プロセスが直接 fs を触るため ScopedFs では保護できず、 +Permission 層(deny/allow ルール)との統合が前提。 + +## 実装内容 + +- コマンド実行(`tokio::process::Command`) +- タイムアウト(`timeout` パラメータ、デフォルト 120秒、最大 600秒) +- 作業ディレクトリの永続(ツール内部で `pwd` 状態を保持、`cd` で変更可能) +- stdout/stderr の結合出力 +- ToolOutput の summary(コマンド + exit code)+ content(出力テキスト) + +## Scope との関係 + +Bash の子プロセスは ScopedFs を経由しない。Scope による保護は不可能。 + +代わりに: +- `PreToolCall` Hook + Permission ルール(`[permission]` マニフェストセクション)で制御 +- Permission 未実装の間は制約なしで動作 + +## 依存チケット + +- [permission-extension-point.md](permission-extension-point.md) — deny/allow ルールによる Bash コマンド制御 diff --git a/tickets/builtin-tools.md b/tickets/builtin-tools.md deleted file mode 100644 index b794da4f..00000000 --- a/tickets/builtin-tools.md +++ /dev/null @@ -1,90 +0,0 @@ -# 組み込みツール実装 (tools クレート) - -## 背景 - -リファレンス仕様 (`docs/ref/reference-tool-spec.md`) に基づき、エージェントの基本操作となる -ファイル操作ツール群と Bash ツールを実装する。 - -新規 `tools` クレートを作成し、llm-worker のツール基盤(`Tool` trait, `ToolServer`)の上に -具体的なツール実装を載せる。 - -## 実装対象 - -| ツール | 概要 | Scope | -|--------|------|-------| -| Read | ファイル読み取り(offset/limit対応) | 制限なし | -| Write | ファイル新規作成/全体上書き | ScopedFs で書き込み制限 | -| Edit | 部分文字列置換(一意性チェック, replace_all) | ScopedFs で書き込み制限 | -| Glob | glob パターンによるファイル検索 | 制限なし | -| Grep | 正規表現によるファイル内容検索 | 制限なし | -| Bash | シェルコマンド実行 | Permission 層で制御(別チケット) | - -## ScopedFs - -ファイル操作の Scope 境界を構造的に保証するラッパー。 - -```rust -pub struct ScopedFs { - scope: Scope, -} - -impl ScopedFs { - // Read 系 — 制限なし - fn read(&self, path: &Path) -> io::Result; - fn read_lines(&self, path: &Path, offset: usize, limit: usize) -> io::Result; - fn glob(&self, pattern: &str, base: Option<&Path>) -> Vec; - fn grep(&self, pattern: &str, path: Option<&Path>, opts: GrepOpts) -> GrepResult; - - // Write 系 — Scope チェック付き - fn write(&self, path: &Path, content: &str) -> io::Result<()>; - fn replace(&self, path: &Path, old: &str, new: &str, all: bool) -> io::Result<()>; -} -``` - -- 各ファイル操作ツール(Read/Write/Edit/Glob/Grep)は ScopedFs を通してのみ fs に触る -- Bash は子プロセスが直接 fs を触るため ScopedFs では守れない → Permission チケットの deny/allow ルールで対応 -- 既存の `manifest::Scope` を ScopedFs 内部で利用。Scope 自体は manifest に残す - -## ツール実装パターン - -```rust -// tools クレートで実装 -pub struct ReadTool { - fs: ScopedFs, -} - -impl Tool for ReadTool { - async fn execute(&self, input: &str) -> Result { - let params: ReadParams = serde_json::from_str(input)?; - self.fs.read_lines(¶ms.file_path, params.offset, params.limit) - .map_err(ToolError::from) - } -} - -// ToolDefinition ファクトリで登録 -pub fn read_tool(fs: ScopedFs) -> ToolDefinition { ... } -``` - -全ツールのファクトリをまとめた登録関数を公開: - -```rust -pub fn builtin_tools(fs: ScopedFs) -> Vec { - vec![read_tool(fs.clone()), write_tool(fs.clone()), ...] -} -``` - -## Bash ツール - -- コマンド実行、タイムアウト、バックグラウンド実行をサポート -- 作業ディレクトリは永続(ツール内部で状態保持) -- Scope による保護は不可 → `PreToolCall` Hook + Permission ルールで制御 -- Permission チケット未実装の間は、ツール自体は登録可能だが制約なしで動作 - -## 依存関係 - -- `tools` クレートは `llm-worker`(Tool trait)と `manifest`(Scope)に依存 -- Permission による Bash 制御 → [permission-extension-point.md](permission-extension-point.md) - -## 将来の拡張 - -- ScopedFs をスクリプティング言語ランタイムに公開し、ユーザー定義ツールからも同じ Scope 境界で fs 操作を可能にする diff --git a/tickets/compact-improvements.md b/tickets/compact-improvements.md index 44494628..bc0b9e1e 100644 --- a/tickets/compact-improvements.md +++ b/tickets/compact-improvements.md @@ -8,7 +8,7 @@ ## 前提チケット - [token-counter.md](token-counter.md) — LlmClient に Tokenizer 導入。retained_tokens / auto-read budget がこれに依存 -- [tool-output-referenced-files.md](tool-output-referenced-files.md) — ToolOutput にファイル追跡フィールド追加。デフォルトリファレンスがこれに依存 +- [tracker.md](tracker.md) — `ReadTracker` → `Tracker` リネーム + `recent_files(n)` 追加。デフォルトリファレンスがこれに依存 --- @@ -37,29 +37,100 @@ --- +## 用語の定義(重要 — 混乱防止のため明記) + +- **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 に根拠がない + +これらをまとめて修正する。値入れ替え + リネーム + マニフェストで両閾値を個別指定。 ### 正しい方針 -- **post-run (タスク区切り) = 早めの閾値**: タスクの区切りで先を見越して compact -- **mid-turn (pre_llm_request) = 遅めの閾値**: ターン中は最終防衛ラインとして、遅くなっても止まらないよう +| チェックポイント | 変数名 (コード) | マニフェスト | 役割 | +|----------------|---------------|------------|------| +| `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 ``` -manifest.compact_threshold → post_run_threshold (基本ライン, 早め) - turn_threshold = post_run_threshold * 9 / 8 (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` 2 本持ち。`exceeds_*` メソッドは `Option` が `None` なら常に `false`。 ### 影響箇所 -- `crates/pod/src/compact_state.rs` - - フィールド名と初期化を入れ替え: `manifest compact_threshold` は `post_run_threshold` に代入 - - `turn_threshold` は `post_run_threshold * 9 / 8` として導出 - - テストの `assert_eq!(state.post_run_threshold, 90_000)` を逆転(`turn_threshold = 90_000`, `post_run_threshold = 80_000` が正) -- `crates/pod/src/compact_interceptor.rs` — そのまま(`exceeds_turn` を呼ぶだけ) -- `crates/pod/src/pod.rs:371` の `exceeds_post_run` 判定 — そのまま -- `docs/compaction.md` — 「ターン間は早めの閾値」の記述を逆に修正 +- **`crates/manifest/src/lib.rs`** + - `CompactionConfig` に `compact_request_threshold: Option` フィールドを追加 + - デフォルトは `None` + - テスト更新 (両閾値が読めること) + +- **`crates/pod/src/compact_state.rs`** + - `turn_threshold` フィールドを `request_threshold: Option` にリネーム + `Option` 化 + - `post_run_threshold: u64` → `Option` に変更 + - コンストラクタシグネチャ変更: + ```rust + // Before + pub fn new(turn_threshold: u64, retained_turns: usize) -> Self + // After + pub fn new( + post_run_threshold: Option, + request_threshold: Option, + retained_turns: usize, + ) -> Self + ``` + - `exceeds_turn()` → `exceeds_request()` にリネーム。中身: + ```rust + pub(crate) fn exceeds_request(&self) -> bool { + self.request_threshold + .map(|t| self.last_input_tokens() > t) + .unwrap_or(false) + } + ``` + - `exceeds_post_run()` も同様に Option 対応 + - `turn_threshold()` getter → `request_threshold()`、戻り値は `Option` + - ドックコメントを「proactive = post_run」「safety net = request」で書き直し + - テスト: 両方設定/片方だけ/両方 None の 3 ケース + +- **`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 で導出」の記述を削除、個別指定である旨に修正 --- @@ -130,9 +201,9 @@ token-counter チケットが前提。 ### デフォルトリファレンスの抽出 -Pod は `ToolOutput.referenced_files` を `HookInterceptor::post_tool_call` で観察し、 -LRU 的な履歴バッファに積む(→ tool-output-referenced-files チケット)。 -Compact 時は先頭 5 件を compact worker のデフォルトリファレンスとして渡す。 +`tools::Tracker` (既存の `ReadTracker` を拡張したもの → [tracker.md](tracker.md)) が +Read/Write/Edit で触られたファイルを LRU で保持している。Compact 時は +`self.tracker.recent_files(5)` で先頭 5 件を compact worker のデフォルトリファレンスとして渡す。 ### compact worker のツール @@ -148,7 +219,7 @@ write_summary(text) — 構造化要約を出力/上書き ### フロー -1. Pod が referenced_files バッファから先頭 5 件を抽出(デフォルトリファレンス) +1. Pod が `Tracker::recent_files(5)` で最近触られたファイルを抽出(デフォルトリファレンス) 2. compact worker のプロンプトに含める: ``` @@ -270,17 +341,17 @@ pruned history から: ### Yield のタイミング精度 -現状 `pre_llm_request`(リクエストの切れ目)でのみチェック。 -1ターン内でツール呼び出しが多く途中でコンテキストが膨らむケースは次のリクエストまで待つ。 +現状 `pre_llm_request`(リクエストの合間)でのみチェック。 +1 turn 内でツール呼び出しが多く途中でコンテキストが膨らむケースは次のリクエストまで待つ。 検討: `post_tool_call` でもチェックする? -### 閾値の比率 +### 閾値の推奨値 -- `post_run_threshold` = マニフェストの `compact_threshold` -- `turn_threshold` = `post_run_threshold * 9 / 8`(≈ 112.5%) +- `compact_threshold` (post_run, proactive): モデルのコンテキスト上限の 70-80% あたりが目安 +- `compact_request_threshold` (request, safety net): `compact_threshold` より少し上、85-95% あたり -9/8 の根拠はない(安全マージン)。要調整。 +両方 manifest で個別指定する(導出はしない)。要調整の余地あり。 ### Prune と Compact の相互作用 @@ -302,12 +373,12 @@ compact 後の新セッションが存在する場合、どちらを restore す ## 実装順序 0. **[前提] token-counter** — LlmClient に Tokenizer -0. **[前提] tool-output-referenced-files** — ToolOutput + Pod の LRU バッファ -1. **閾値逆転の修正** — `compact_state.rs` のフィールド入れ替え、テスト修正、docs 更新 +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 を除去 3. **retained_tokens 化** — retained_turns → retained_tokens に変更。マニフェスト設定追加 4. **compact worker のツール化** — read_file + mark_read_required + add_reference + write_summary (上書き可) -5. **Auto-Read + リファレンス** — デフォルト5ファイル抽出 (referenced_files バッファから)、compact worker による選定、system message での注入 +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. **要約フォーマット** — タスク分類の要約プロンプト調整 diff --git a/tickets/scope-redesign.md b/tickets/scope-redesign.md new file mode 100644 index 00000000..630da742 --- /dev/null +++ b/tickets/scope-redesign.md @@ -0,0 +1,86 @@ +# Scope の再設計 + +## 背景 + +Scope は Pod の作業ディレクトリとアクセス権限を定義するもの。全ての Pod が必ず持つ。 + +現状の問題: +1. `Pod.scope` が `Option` — scope なしの Pod が存在しうる(ツールが登録されない) +2. `Scope` が `root: PathBuf` しか持たない — 書き込み許可/禁止の概念がない +3. Scope なしの場合、Glob/Grep のデフォルト検索パスがない +4. マニフェストで `[scope]` 省略時のフォールバックが定義されていない + +## 方針 + +### Scope の拡張 + +```rust +pub struct Scope { + pwd: PathBuf, // 作業ディレクトリ + writable: bool, // false = 読み取り専用 Pod +} +``` + +- `writable: true`(デフォルト): Read/Write/Edit/Glob/Grep 全て使える +- `writable: false`: Read/Glob/Grep のみ。Write/Edit はエラー +- `pwd` は Glob/Grep のデフォルト検索パスとしても使われる + +### Pod で必須化 + +```rust +pub struct Pod { + scope: Scope, // Option ではない + // ... +} +``` + +### マニフェスト + +```toml +# 明示指定 +[scope] +pwd = "./src" +writable = false # 省略時 true + +# [scope] 省略時 → マニフェストファイルの親ディレクトリが pwd、writable = true +``` + +`Pod::new()`(マニフェストなし構築)では Scope を引数で必須にする。 + +### ScopedFs の変更 + +```rust +pub fn write(&self, path: &Path, content: &[u8]) -> Result { + if !self.inner.scope.writable() { + return Err(ToolsError::ReadOnly); + } + if !self.inner.scope.contains(path) { + return Err(ToolsError::OutOfScope(path.to_path_buf())); + } + // ... +} +``` + +### Controller 統合 + +```rust +// 今: scope が Some のときだけツール登録 +if let Some(scope) = scope_for_tools { ... } + +// 後: 常にツール登録(scope は必須) +let fs = tools::ScopedFs::new(pod.scope().clone()); +let tracker = tools::ReadTracker::new(); +worker.register_tools(tools::builtin_tools(fs, tracker)); +``` + +## 影響範囲 + +- `manifest::Scope` — `root` → `pwd`、`writable` フィールド追加 +- `manifest::ScopeConfig` — `root` → `pwd`、`writable` の serde 対応 +- `manifest::PodManifest` — [scope] 省略時のフォールバック解決 +- `Pod` — `scope: Option` → `scope: Scope` +- `Pod::new()`, `Pod::restore()`, `Pod::from_manifest()` — シグネチャ変更 +- `ScopedFs` — writable チェック追加 +- `Controller` — scope の Optional 分岐を削除 +- `tools::error::ToolsError` — `ReadOnly` variant 追加 +- `Scope::contains` — `root` → `pwd` のリネーム diff --git a/tickets/tool-output-design.md b/tickets/tool-output-design.md deleted file mode 100644 index 4a1882df..00000000 --- a/tickets/tool-output-design.md +++ /dev/null @@ -1,160 +0,0 @@ -# ツール出力の設計 - -## 課題 - -ツール実行結果(ファイル内容、検索結果等)はサイズが予測不能で、 -全量を LLM コンテキストに載せるとトークン消費が爆発する。 - -## 方針 - -ツール出力を **summary(常駐)** と **content(prunable)** の2フィールドに分離する。 - -- summary: 1-2行。常に history に残る。Prune 後もこれだけで「何をしたか」がわかる -- content: 詳細な出力。一定閾値まで。Prune で消える - -巨大な出力(大量の grep 結果、巨大ファイル等)はフレームワークの責務外。 -ツール側がファイルに書き出し、content に見取り図を置く。 - -## データ型 - -### ToolOutput - -```rust -/// ツール実行結果。 -/// -/// summary は常に必須。content は省略可能。 -/// Prune 時に content が除去され、summary だけが残る。 -pub struct ToolOutput { - /// 1-2行の要約。Prune 後も history に残る。 - /// 例: "read_file: src/main.rs — 42 lines" - /// 例: "bash: cargo test — exit 0, 3 passed" - /// 例: "grep: TODO in src/ — 128 hits, saved to /tmp/grep_result.txt" - pub summary: String, - - /// 詳細な出力内容。Prune で消える。 - /// None の場合、summary のみが history に載る。 - pub content: Option, -} -``` - -### Item::ToolResult - -```rust -Item::ToolResult { - id: Option, - call_id: CallId, - /// 1-2行の要約。Prune 後も残る。 - summary: String, - /// 詳細な出力。Prune で None に置換される。 - content: Option, -} -``` - -LLM への送信時は summary + content を結合して単一文字列にする。 -content が None の場合は summary のみ。 - -```rust -impl Item { - /// LLM に送信する出力文字列を構築。 - pub fn tool_result_text(&self) -> Option<&str> { - match self { - Item::ToolResult { summary, content: Some(c), .. } => { - // 呼び出し側で結合 - None // 実際は format!("{summary}\n{c}") - } - Item::ToolResult { summary, content: None, .. } => Some(summary), - _ => None, - } - } -} -``` - -### Tool trait の変更 - -`Tool::execute()` の戻り値を `Result` に変更する。 - -```rust -#[async_trait] -pub trait Tool: Send + Sync { - async fn execute(&self, input_json: &str) -> Result; -} -``` - -ツールが独自の summary を付けたい場合は `ToolOutput` を直接構築する。 -単純なケースでは `From` で自動変換できる: `Ok("result".to_string().into())` - -### From\ 変換 - -`From` による自動変換: - -```rust -impl From for ToolOutput { - fn from(s: String) -> Self { - if s.len() <= SUMMARY_THRESHOLD { - // 小さい出力: summary のみ(content なし) - ToolOutput { summary: s, content: None } - } else { - // summary = 先頭行 + メタ情報 - let lines = s.lines().count(); - let first_line: String = s.lines().next() - .unwrap_or("") - .chars().take(80) - .collect(); - let summary = format!("{lines} lines | {first_line}…"); - ToolOutput { summary, content: Some(s) } - } - } -} -``` - -`SUMMARY_THRESHOLD`: summary のみで十分な小さい出力の閾値。 -具体値は調整するが、数百バイト程度を想定。 - -## Prune との関係 - -``` -ツール実行 - → ToolOutput { summary, content } - → Item::ToolResult { summary, content } ← history に追加 - - ─── 数ターン経過 ─── - -Prune(pre_llm_request フック) - → Item::ToolResult { summary, content: None } ← content を除去 -``` - -Prune の実装は `content = None` にするだけ。 - -prunable トークン数の推定: -- `content.as_ref().map(|c| c.len() / 4).unwrap_or(0)` - -## 巨大出力の扱い - -フレームワークは巨大出力を特別扱いしない。 -ツール側が自分で判断して対処する。 - -``` -巨大な grep 結果 → ツールがファイルに書き出す - → summary: "grep: TODO in src/ — 128 hits" - → content: ファイルパス + ヒット数の内訳(見取り図) - -巨大なファイル読み取り → ツールが部分読み取りを提案 - → summary: "read_file: data.csv — 50,000 lines" - → content: 先頭 N 行 + 末尾 M 行 -``` - -LLM が詳細を見たい場合は、read_file / grep 等の汎用ツールで -ファイルを直接参照する。専用の inspect ツールは不要。 - -## 削除対象(旧設計からの移行) - -| モジュール | 理由 | -|---|---| -| `ToolOutput` enum(Inline/Stored) | struct に置換 | -| `Content` enum(Text/Structured) | 不要 | -| `auto_summarize` / `auto_summarize_text` / `auto_summarize_structured` | 不要 | -| `ToolOutputProcessor` trait | 不要 | -| `BlobOutputProcessor` | 不要 | -| `BlobStore` trait / `FsBlobStore` | 不要 | -| `inspect_tool.rs` | 不要 | -| Worker の `output_processor` フィールド | 不要 | diff --git a/tickets/tool-output-referenced-files.md b/tickets/tool-output-referenced-files.md deleted file mode 100644 index 55d213e4..00000000 --- a/tickets/tool-output-referenced-files.md +++ /dev/null @@ -1,76 +0,0 @@ -# ToolOutput.referenced_files: ツールが参照したファイルの追跡 - -## 背景 - -Compact 実行時、「タスク続行に必要なファイル」を compact worker に提示するため -のデフォルトリファレンス(過去に読み書きされたファイル一覧)が必要。 -builtin-tools は実装済みだが、Pod 側でファイル操作を追跡する口がない。 - -ツール名ベースのヒューリスティック検出("read" / "edit" / "write" の名前で判別)は -脆弱でユーザー定義ツールに対応できない。各ツールが自己申告する形にする。 - -## 方針 - -`ToolOutput` に optional な `referenced_files: Vec` を追加し、ツール自身が -「この実行で触ったファイル」を申告する。Pod の `HookInterceptor::post_tool_call` で -観察し、Pod 内の LRU 的な履歴に積む。Compact 時はその履歴から先頭 N 件を compact -worker のデフォルトリファレンスとして渡す。 - -## 実装 - -### llm-worker: ToolOutput 拡張 - -```rust -pub struct ToolOutput { - pub summary: String, - pub content: Option, - pub referenced_files: Vec, // NEW. 空なら未申告 -} -``` - -- `From` 実装では空の Vec を入れる -- 既存のツール実装は変更不要(デフォルトで空) -- `ToolResultInfo` (`post_tool_call` Hook が受ける型)にも伝播させて Pod から見えるようにする - -### builtin-tools: 各ツールで埋める - -既に実装済みの以下のツールに `referenced_files` を埋める変更を入れる: - -| ツール | 申告するファイル | -|--------|------------------| -| Read | 読んだファイルパス | -| Write | 書いたファイルパス | -| Edit | 編集したファイルパス | -| Glob | (積極的には埋めない — マッチしたファイル全部は多すぎる) | -| Grep | (同上) | -| Bash | (不可能。空のまま) | - -Glob/Grep は「発見」しかしていないので referenced とは扱わない。 - -### Pod: 追跡と取り出し - -`Pod` 内に履歴バッファ: - -```rust -referenced_files: VecDeque, // 最新 N 件, 重複排除 -``` - -- `HookInterceptor::post_tool_call` で `ToolResultInfo.referenced_files` を受け取り、 - 既存エントリを先頭に移動 or 新規追加(LRU 的な挙動) -- 容量上限(例: 20)を超えたら末尾を落とす -- Compact 開始時に先頭 5 件を取り出して compact worker に渡す - -## 設計ポイント - -- **ツールが自己申告**: 外部から名前で判別しない。拡張性のため -- **Vec なので 0〜複数件**: 複数ファイルに触るツール (例: 大量置換) にも対応 -- **空 Vec 許容**: Bash や Glob のような「追跡不能 or 不適切」なツールはそのまま空 -- **Pod が LRU バッファを持つ**: Compact 時の抽出を O(1) に近づける。Worker 層は関知しない - -## 依存 - -- builtin-tools が実装済み前提(チケット: [builtin-tools.md](builtin-tools.md) 完了後) - -## ブロックする後続 - -- [compact-improvements.md](compact-improvements.md) — デフォルトリファレンスの抽出がこのフィールドに依存 diff --git a/tickets/tracker.md b/tickets/tracker.md new file mode 100644 index 00000000..f72d3188 --- /dev/null +++ b/tickets/tracker.md @@ -0,0 +1,112 @@ +# Tracker: ReadTracker のリネームと機能追加 + +## 背景 + +`tools::ReadTracker` は既に Read/Write/Edit のすべてでファイル操作を記録している +(`record()` が各ツールから呼ばれる)。名前に反して「read 以外も追跡している」状態。 + +また compact の改善 ([compact-improvements.md](compact-improvements.md)) で +「最近触られたファイル一覧」をデフォルトリファレンスとして compact worker に渡したい +要求があり、既存の Tracker を拡張すれば自然に解決する。 + +## 方針 + +1. `ReadTracker` → `Tracker` にリネーム (crate 全体) +2. 順序付き (recency) の履歴を追加 +3. `recent_files(n)` メソッドで最近 N 件を取得できるようにする +4. Pod が Tracker を保持して compact 時に参照 + +## 実装 + +### リネーム + +- `crates/tools/src/read_tracker.rs` → `crates/tools/src/tracker.rs` +- `pub struct ReadTracker` → `pub struct Tracker` +- `crates/tools/src/lib.rs` の pub 再公開 (`pub use read_tracker::ReadTracker` → `pub use tracker::Tracker`) +- crate 内呼び出し側 (`read.rs`, `write.rs`, `edit.rs`, `scoped_fs.rs` など) +- テスト (`tests/integration.rs`, `tests/edge_cases.rs`) +- `crates/pod/src/controller.rs:172` の `tools::ReadTracker::new()` → `tools::Tracker::new()` + +### 内部構造の変更 + +```rust +#[derive(Debug, Clone, Default)] +pub struct Tracker { + inner: Arc>, +} + +#[derive(Debug, Default)] +struct Inner { + hashes: HashMap, + recency: VecDeque, // 先頭が最新 +} + +const RECENCY_CAPACITY: usize = 20; +``` + +### `record()` の挙動追加 + +既存の hash 記録に加えて: + +```rust +pub fn record(&self, path: &Path, bytes: &[u8]) { + let key = canonicalize_or_owned(path); + let hash = hash_bytes(bytes); + let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + inner.hashes.insert(key.clone(), hash); + + // LRU: 既存エントリを除去 → 先頭に push + inner.recency.retain(|p| p != &key); + inner.recency.push_front(key); + if inner.recency.len() > RECENCY_CAPACITY { + inner.recency.pop_back(); + } +} +``` + +### 新メソッド + +```rust +/// Return up to `n` most recently recorded file paths. +/// Order: most recent first. +pub fn recent_files(&self, n: usize) -> Vec { + let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + inner.recency.iter().take(n).cloned().collect() +} +``` + +### Pod への接続 + +- `Pod` に `tracker: Option` フィールド追加 (builtin-tools 未登録の場合は None) +- Controller が `Tracker::new()` した時点で Pod にも `attach_tracker(tracker.clone())` で共有 +- Compact 実行時 (`Pod::compact` 内) に `self.tracker.as_ref().map(|t| t.recent_files(5))` でデフォルトリファレンスを取得 + +### ライフサイクルの整合性 + +既存のドキュメント: 「Tracker は session-scoped。Controller spawn ごとに new」 +この方針は維持。compact 後も同じ Controller spawn 内で状態が継続するので、 +compact worker が `read_file` で追加で参照したファイルも次回 compact 時に効く。 + +### テスト追加 + +- `recent_files` が recency 順で返ること +- `RECENCY_CAPACITY` を超えた場合に古いものが落ちること +- 既存パスを再 record したら先頭に移動すること +- Read/Write/Edit 実行後に recent_files に現れること (integration テスト) + +## 影響範囲 + +- `crates/tools/src/read_tracker.rs` — リネーム + Inner 構造体化 + recency フィールド + recent_files メソッド +- `crates/tools/src/lib.rs` — pub use 修正 +- `crates/tools/src/{read,write,edit,scoped_fs}.rs` — 型名追従 +- `crates/tools/tests/*` — 型名追従 + recent_files のテスト追加 +- `crates/pod/src/pod.rs` — `tracker` フィールド + `attach_tracker` メソッド +- `crates/pod/src/controller.rs` — `tracker.clone()` を Pod にも渡す + +## 依存 + +- なし (単独で実装可能) + +## ブロックする後続 + +- [compact-improvements.md](compact-improvements.md) — デフォルトリファレンスの抽出がこれに依存