Tickets整理

This commit is contained in:
Keisuke Hirata 2026-04-12 03:19:12 +09:00
parent 8e394005b2
commit 29e1bc8253
4 changed files with 598 additions and 35 deletions

View File

@ -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)

90
tickets/builtin-tools.md Normal file
View File

@ -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<String>;
fn read_lines(&self, path: &Path, offset: usize, limit: usize) -> io::Result<String>;
fn glob(&self, pattern: &str, base: Option<&Path>) -> Vec<PathBuf>;
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<String, ToolError> {
let params: ReadParams = serde_json::from_str(input)?;
self.fs.read_lines(&params.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<ToolDefinition> {
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 操作を可能にする

View File

@ -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: PruneWorker 層)
### 概要
`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<PreLlmRequest>` 実装) |
アルゴリズムは `Item` を操作する純粋関数なので llm-worker に置く。
フックの配線は Pod 層の責務。
### アルゴリズム
```rust
// crates/llm-worker/src/prune.rs
/// 古いターンのツール出力を刈り込む。
///
/// `items` はリクエストコンテキストhistory のクローン)。
/// 直近 `protected_turns` ターン以内のアイテムは保護される。
pub fn prune(items: &mut Vec<Item>, protected_turns: usize) {
// 1. ターン境界の特定
// UserMessage の出現位置 = ターンの開始点
let turn_starts: Vec<usize> = 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<String> {
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: CompactPod 層)
```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<PreLlmRequest> for PruneHook {
async fn call(&self, context: &mut Vec<Item>) -> 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<C, St>(
pod: &mut Pod<C, St>,
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<Item>, Vec<Item>) {
let turn_starts: Vec<usize> = 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<Item>,
},
}
```
`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<ScopeConfig>,
#[serde(default)]
pub compaction: Option<CompactionConfig>,
}
#[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<u64>,
#[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<PreLlmRequest>` + 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<PreLlmRequest>` として利用可能
- ~~[remove-hook-module.md](remove-hook-module.md)~~ — 完了。`PreLlmRequest` は Pod 層の `hook::Hook<PreLlmRequest>` として利用可能

View File

@ -0,0 +1,69 @@
# Protocol: request-response パターン導入
## 背景
現在の Pod Protocol は fire-and-forgetMethod 送信)+ broadcastEvent 受信)のみ。
クライアントが 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<Item> }, // 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 チケットで扱う。