From 1617a982e1a64159b3ab13ccaa6b529874bfc1b5 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 12 Apr 2026 03:19:12 +0900 Subject: [PATCH] =?UTF-8?q?Tickets=E6=95=B4=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO.md | 2 + tickets/builtin-tools.md | 90 +++++ tickets/context-compaction.md | 472 +++++++++++++++++++++++++-- tickets/request-response-protocol.md | 69 ++++ 4 files changed, 598 insertions(+), 35 deletions(-) create mode 100644 tickets/builtin-tools.md create mode 100644 tickets/request-response-protocol.md diff --git a/TODO.md b/TODO.md index 8cc5b8ce..1c5b6d18 100644 --- a/TODO.md +++ b/TODO.md @@ -4,6 +4,7 @@ - [ ] ツール設計 - [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] inspect ツール実装 - [x] max_turns: マニフェストによるターン数制限 - [x] pod バイナリエントリポイント @@ -13,4 +14,5 @@ - [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) - [ ] コンテキスト圧縮 (Prune + Compact) → [tickets/context-compaction.md](tickets/context-compaction.md) +- [ ] Protocol: request-response パターン (GetHistory等) → [tickets/request-response-protocol.md](tickets/request-response-protocol.md) - [ ] パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md) diff --git a/tickets/builtin-tools.md b/tickets/builtin-tools.md new file mode 100644 index 00000000..b794da4f --- /dev/null +++ b/tickets/builtin-tools.md @@ -0,0 +1,90 @@ +# 組み込みツール実装 (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/context-compaction.md b/tickets/context-compaction.md index 65b82034..a4d97650 100644 --- a/tickets/context-compaction.md +++ b/tickets/context-compaction.md @@ -5,63 +5,465 @@ 長時間実行エージェントにとって、コンテキストウィンドウの管理はコア要件。 現状の Worker は history をそのまま保持し、オーバーフロー時の対策がない。 -OpenCode は2段階のアプローチを採る: -1. **Prune**: 古いツール出力を削除してトークンを回収 -2. **Compact**: 専用エージェントで会話全体を構造化要約に圧縮 +2段階のアプローチで対処する: +1. **Prune**: リクエストごとに古いツール出力を削ぎ落とし、コンテキストを節約 +2. **Compact**: 閾値超過時に要約を生成し、history 全体を圧縮 -Insomnia では Hook ベースで同等の機能を実現できる。 +--- -## 方針 +## Phase 1: Prune -### Phase 1: Prune(Worker 層) +### 概要 -`PreLlmRequest` 相当のポイントで、古いツール出力を除去する。 +`PreLlmRequest` フックとして実装する。リクエストコンテキスト(history のクローン)上で動作し、実際の history は変更しない。セッションログの完全性を保ちつつ、LLM に送るコンテキストを軽量化する。 -``` -history 内のツール出力を走査: - - 直近 N ターン以内 → 保護 - - それ以前 → 出力を "[pruned — stored as blob {id}]" に置換 +### コード配置 + +| 場所 | 内容 | +|------|------| +| `crates/llm-worker/src/prune.rs` | Prune アルゴリズム(純粋関数) | +| `crates/pod/src/prune_hook.rs` | `PruneHook`(`Hook` 実装) | + +アルゴリズムは `Item` を操作する純粋関数なので llm-worker に置く。 +フックの配線は Pod 層の責務。 + +### アルゴリズム + +```rust +// crates/llm-worker/src/prune.rs + +/// 古いターンのツール出力を刈り込む。 +/// +/// `items` はリクエストコンテキスト(history のクローン)。 +/// 直近 `protected_turns` ターン以内のアイテムは保護される。 +pub fn prune(items: &mut Vec, protected_turns: usize) { + // 1. ターン境界の特定 + // UserMessage の出現位置 = ターンの開始点 + let turn_starts: Vec = items + .iter() + .enumerate() + .filter(|(_, item)| item.is_user_message()) + .map(|(i, _)| i) + .collect(); + + // 2. 保護境界の計算 + // 直近 N ターンの最初の UserMessage のインデックス + let protection_boundary = if turn_starts.len() <= protected_turns { + return; // 保護対象以内ならスキップ + } else { + turn_starts[turn_starts.len() - protected_turns] + }; + + // 3. 境界より前のアイテムを刈り込み + for item in items[..protection_boundary].iter_mut() { + prune_item(item); + } +} + +fn prune_item(item: &mut Item) { + match item { + Item::ToolResult { output, .. } => { + if output == "[pruned]" || output.starts_with("[pruned]") { + return; // 冪等性: 既に刈り込み済み + } + // blob 参照があれば保持し、サマリーだけ除去 + if let Some(blob_ref) = extract_blob_ref(output) { + *output = format!("[pruned] {blob_ref}"); + } else { + *output = "[pruned]".to_string(); + } + } + Item::Reasoning { text, .. } => { + *text = "[pruned]".to_string(); + } + // UserMessage, AssistantMessage, ToolCall は保持 + // (会話の流れとツール呼び出しの意図は残す) + _ => {} + } +} + +/// "[blob:abc123] summary..." から "[blob:abc123]" を抽出 +fn extract_blob_ref(output: &str) -> Option { + if output.starts_with("[blob:") { + output.find(']').map(|end| output[..=end].to_string()) + } else { + None + } +} ``` -- Blob Storage にはすでに退避済み(llm-worker-persistence の Stored 出力) -- Prune は参照を残して本文だけ削る操作 -- Worker の `Mutable` 状態で history 編集が可能 +### PruneHook -### Phase 2: Compact(Pod 層) +```rust +// crates/pod/src/prune_hook.rs -トークン数が閾値を超えた場合、Controller が要約を挿入する。 +pub struct PruneHook { + protected_turns: usize, +} -``` -1. OnTurnEnd でトークン使用量をチェック -2. 閾値超過 → Controller が要約生成を実行 -3. history を [system_prompt, compaction_summary, 直近の会話] に圧縮 -4. resume で作業を継続 +impl PruneHook { + pub fn new(protected_turns: usize) -> Self { + Self { protected_turns } + } +} + +#[async_trait] +impl Hook for PruneHook { + async fn call(&self, context: &mut Vec) -> PreRequestAction { + prune(context, self.protected_turns); + PreRequestAction::Continue + } +} ``` -要約フォーマット(OpenCode の構造化要約を参考): +### 特性 + +- **冪等**: 既に `[pruned]` のアイテムは再処理しない +- **非破壊**: history 本体は変更せず、リクエストコンテキスト(クローン)のみ操作 +- **blob 参照保持**: `[pruned] [blob:abc123]` の形式で blob 参照を残す。LLM は `inspect` ツールで必要に応じて内容を取得可能 +- **対象**: `ToolResult`(最大の節約源)と `Reasoning`。`ToolCall` の arguments は残す(ツール操作の意図が消えるため) + +### KV キャッシュへの影響 + +`pre_llm_request` はリクエストコンテキスト(クローン)を操作する。プロバイダ側の KV キャッシュは、送信内容が変わった部分で再計算が必要。ただし刈り込み対象は古いアイテムであり、キャッシュヒットしない領域なのでトレードオフとして許容。 + +--- + +## Phase 2: Compact + +### 概要 + +Prune がアイテム単位の軽量な刈り込みであるのに対し、Compact は history 全体を要約で置き換える重量級の操作。別の Worker(要約専用・ツールなし)を使って要約を生成し、history を圧縮する。 + +### トリガー + +Controller が `input_tokens` を追跡し、run 完了後に閾値と比較する。 + +```rust +// controller.rs 内の actor ループ + +// 使用量トラッカー(セットアップ時に Worker コールバックに登録) +let last_input_tokens = Arc::new(AtomicU64::new(0)); +{ + let tracker = last_input_tokens.clone(); + worker.on_usage(move |event| { + if let Some(tokens) = event.input_tokens { + tracker.store(tokens, Ordering::Relaxed); + } + }); +} + +// run 完了後のチェック(actor ループ内) +let input_tokens = last_input_tokens.load(Ordering::Relaxed); +if let Some(threshold) = compact_threshold { + if input_tokens > threshold { + // → compaction 実行 + } +} +``` + +### Compaction フロー ``` -## Goal -(元のユーザー指示) +Run 完了 + ↓ +Controller: input_tokens > threshold? + ↓ yes +Controller: history 全体を要約プロンプトに変換 + ↓ +Controller: 要約用 Worker を生成(ツールなし、専用 system prompt) + ↓ +要約 Worker: 要約テキストを生成 + ↓ +Controller: 要約 + 直近 N ターンで新しい history を構築 + ↓ +Controller: pod.session_mut().worker_mut().set_history(compacted) + ↓ +Controller: セッションログに Compacted エントリを記録 + ↓ +次の run/resume で圧縮済み history を使用 +``` -## Accomplished -(完了した作業の箇条書き) +### 要約用 Worker + +```rust +// controller.rs 内、compaction 実行部分 + +async fn compact( + pod: &mut Pod, + retained_turns: usize, +) -> Result<(), PodError> +where + C: LlmClient + 'static, + St: Store + 'static, +{ + let manifest = pod.manifest().clone(); + let history = pod.session_mut().worker_mut().history().to_vec(); + + // 1. 直近 N ターンのアイテムを分離 + let (old_items, recent_items) = split_at_turn_boundary(&history, retained_turns); + + if old_items.is_empty() { + return Ok(()); // 圧縮対象なし + } + + // 2. 要約用 Worker を構築 + let client = provider::build_client(&manifest.provider, None)?; + let mut summary_worker = Worker::new(client); + summary_worker.set_system_prompt(COMPACTION_SYSTEM_PROMPT); + summary_worker.set_request_config( + RequestConfig::new() + .with_max_tokens(2048) + .with_temperature(0.0), + ); + + // 3. 会話履歴を要約対象テキストとして入力 + let summary_input = format_history_for_summary(&old_items); + let locked = summary_worker.lock(); + let output = locked.run(summary_input).await; + let summary_worker = output.worker.unlock(); + + // 4. 要約テキストを取得 + let summary_text = extract_last_assistant_text(summary_worker.history()) + .unwrap_or_else(|| "[compaction failed]".to_string()); + + // 5. 新しい history を構築 + let summary_item = Item::user_message(format!( + "[Compaction Summary — previous conversation condensed]\n\n{summary_text}" + )); + let mut compacted = vec![summary_item]; + compacted.extend(recent_items); + + // 6. 適用 + pod.session_mut().worker_mut().set_history(compacted); + + Ok(()) +} +``` + +### 要約フォーマット + +要約用 Worker の system prompt: + +``` +You are a conversation summarizer for an AI coding assistant. + +Given a conversation history between a user and an assistant, produce a structured +summary. The summary will replace the conversation history, so include all +information the assistant needs to continue working effectively. + +Format: + +## Original Task +(The user's original goal or instruction) + +## Completed Work +- (Bullet list of what was accomplished, with specific file paths and changes) ## Key Discoveries -(判明した事実・制約) +- (Important facts, constraints, decisions, or errors encountered) ## Current State -(ファイル変更・残タスク) +- (What files were modified, what remains to be done) + +Be precise about file paths, function names, and technical details. +Omit pleasantries and conversational filler. ``` -## 設計ポイント +### 直近ターンの分離 -- Phase 1 は Worker 層の拡張。llm-worker に `prune` 機能を追加 -- Phase 2 は Pod 層の制御。Controller が別の Worker(要約用)を起動する -- 要約用 Worker は短命で、ツールなし・プロンプトのみ -- OpenCode の「Replay」(圧縮後に前回のメッセージを再送)は `resume()` で自然に実現可能 -- 設計原則3: 新しい trait は不要。Worker の history 操作 + Controller の制御で完結 +```rust +/// history を「古い部分」と「直近 N ターン」に分割する。 +/// ターン境界は UserMessage の出現で判定。 +fn split_at_turn_boundary( + items: &[Item], + retained_turns: usize, +) -> (Vec, Vec) { + let turn_starts: Vec = items + .iter() + .enumerate() + .filter(|(_, item)| item.is_user_message()) + .map(|(i, _)| i) + .collect(); + + if turn_starts.len() <= retained_turns { + return (vec![], items.to_vec()); // 全て保護 + } + + let split_at = turn_starts[turn_starts.len() - retained_turns]; + let old = items[..split_at].to_vec(); + let recent = items[split_at..].to_vec(); + (old, recent) +} +``` + +### セッションログ + +新しい `LogEntry` variant を追加: + +```rust +// session_log.rs + +pub enum LogEntry { + // ... existing variants ... + + /// Context compaction: history was replaced with a summary + recent items. + Compacted { + ts: u64, + /// The new compacted history. + history: Vec, + }, +} +``` + +`collect_state` での処理: + +```rust +LogEntry::Compacted { history, .. } => { + state.history = history.clone(); +} +``` + +append-only のログ整合性を維持。圧縮前の全履歴はログの過去エントリに残る。 + +### Controller の変更 + +Controller の actor ループに compaction ロジックを追加: + +```rust +// controller.rs (actor ループ内、run 完了後) + +Method::Run { input } => { + // ... existing run logic ... + + // Compaction check + let input_tokens = last_input_tokens.load(Ordering::Relaxed); + if let Some(threshold) = compaction_config.compact_threshold { + if input_tokens > threshold { + info!(input_tokens, threshold, "Triggering context compaction"); + let _ = event_tx.send(Event::CompactionStart); + match compact(&mut pod, compaction_config.retained_turns).await { + Ok(()) => { + let _ = event_tx.send(Event::CompactionDone); + // セッションログに記録 + // ... + } + Err(e) => { + warn!(error = %e, "Compaction failed, continuing without"); + } + } + } + } +} +``` + +### エラーハンドリング + +Compaction は best-effort。失敗してもデータは失われない: +- 要約 Worker がエラー → ログに警告を出して続行。次の run 完了後に再試行 +- 要約テキストの抽出に失敗 → フォールバック: 古い history をそのまま保持 + +--- + +## 設定 + +### マニフェスト拡張 + +```toml +[pod] +name = "code-agent" + +[provider] +kind = "anthropic" +model = "claude-sonnet-4-20250514" + +[worker] +system_prompt = "..." +max_tokens = 8192 + +[compaction] +# Prune: 直近何ターンを保護するか(デフォルト: 3) +prune_protected_turns = 3 + +# Compact: input_tokens がこの値を超えたら要約を実行(省略 = 無効) +compact_threshold = 80000 + +# Compact: 圧縮後に保持するターン数(デフォルト: 2) +compact_retained_turns = 2 +``` + +```rust +// manifest/src/lib.rs + +pub struct PodManifest { + pub pod: PodMeta, + pub provider: ProviderConfig, + pub worker: WorkerManifest, + #[serde(default)] + pub scope: Option, + #[serde(default)] + pub compaction: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompactionConfig { + #[serde(default = "default_prune_protected_turns")] + pub prune_protected_turns: usize, // default: 3 + pub compact_threshold: Option, + #[serde(default = "default_compact_retained_turns")] + pub compact_retained_turns: usize, // default: 2 +} +``` + +### デフォルト動作 + +- `[compaction]` セクション省略時: Prune も Compact も無効 +- `[compaction]` セクションあり・`compact_threshold` 省略時: Prune のみ有効 + +--- + +## Protocol 拡張 + +Compact イベントをクライアントに通知: + +```rust +// protocol/src/lib.rs + +pub enum Event { + // ... existing ... + CompactionStart, + CompactionDone, +} +``` + +--- + +## 設計判断 + +| 判断 | 理由 | +|------|------| +| Prune は request context(クローン)を操作 | history 本体を保全。セッションログに完全な履歴が残る | +| Compact は run 間で実行(mid-loop ではない) | 要約生成は LLM 呼び出しを伴う重い処理。ターンループ内で中断すると複雑性が増す。Prune がループ内のコンテキスト膨張を抑制するので十分 | +| 要約は UserMessage として挿入 | LLM がコンテキストとして自然に参照できる。system prompt とは分離 | +| `LogEntry::Compacted` で新 history を記録 | append-only チェーンを破らず、`collect_state` で正しく復元可能 | +| Compact 失敗は best-effort | データ喪失リスクをゼロにする。失敗しても次回の run 後に再試行可能 | +| 新しい trait は不要 | 設計原則3: `Hook` + Controller 制御 + `set_history()` の組み合わせで完結 | + +--- + +## 実装順序 + +1. **`prune.rs`** — llm-worker にアルゴリズムを追加。単体テスト +2. **`PruneHook`** — pod に Hook 実装。`Pod::add_pre_llm_request_hook` で登録 +3. **`CompactionConfig`** — manifest にセクション追加。パースのテスト +4. **`LogEntry::Compacted`** — session_log に variant 追加。`collect_state` テスト +5. **`compact()` 関数** — Controller に compaction ロジック。統合テスト +6. **Protocol** — `CompactionStart` / `CompactionDone` イベント追加 + +Phase 1(ステップ 1-2)と Phase 2 の準備(ステップ 3-4)は並行可能。 + +--- ## 依存チケット -- ~~[remove-hook-module.md](remove-hook-module.md)~~ — 完了。PreLlmRequest は Pod 層の `hook::Hook` として利用可能 +- ~~[remove-hook-module.md](remove-hook-module.md)~~ — 完了。`PreLlmRequest` は Pod 層の `hook::Hook` として利用可能 diff --git a/tickets/request-response-protocol.md b/tickets/request-response-protocol.md new file mode 100644 index 00000000..f829a5ce --- /dev/null +++ b/tickets/request-response-protocol.md @@ -0,0 +1,69 @@ +# Protocol: request-response パターン導入 + +## 背景 + +現在の Pod Protocol は fire-and-forget(Method 送信)+ broadcast(Event 受信)のみ。 +クライアントが Pod に問い合わせて応答を受け取る request-response パターンがない。 + +これが必要になるケース: +1. **GetHistory**: TUI 接続時にセッション履歴を取得 +2. **Permission ask**: ツール実行の許可をクライアントに問い合わせ(permission-extension-point チケット) + +## 設計 + +### 方式: handle_connection 内での直接応答 + +broadcast channel を変更せず、接続ごとの writer に直接返す。 + +```rust +// handle_connection 内 +match method { + Method::GetHistory => { + // broadcast を経由せず、要求元の writer に直接返す + let items = handle.shared_state.history(); + writer.write(&Event::History { items }).await; + } + other => handle.send(other).await, // 既存: controller へ転送 +} +``` + +- broadcast の仕組みに手を入れない +- 読み取り系は SharedState から直接返せる +- controller を経由する必要がない + +### Protocol 変更 + +```rust +// Method 追加 +enum Method { + Run { input: String }, + Resume, + Cancel, + GetHistory, // NEW + // 将来: PermissionReply // permission チケットで追加 +} + +// Event 追加 +enum Event { + // ... 既存 ... + History { items: Vec }, // NEW: GetHistory への応答 +} +``` + +### TUI 接続フロー + +``` +TUI connect + → send GetHistory + ← recv History { items } ← 直接応答(この接続のみ) + → 履歴表示 + ← recv TextDelta, ... ← broadcast(通常のイベントストリーム) +``` + +## 将来の拡張 + +Permission の `ask` は双方向のやりとりが必要で、より複雑: +- Pod → Client: `Event::PermissionRequest { id, tool, args }` +- Client → Pod: `Method::PermissionReply { id, allow: bool }` + +これは request-response の逆方向(Pod が要求元)になるが、同じソケット上の双方向通信として自然に実現できる。詳細は permission-extension-point チケットで扱う。