yoi/tickets/tool-output-design.md

161 lines
5.1 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.

# ツール出力の設計
## 課題
ツール実行結果(ファイル内容、検索結果等)はサイズが予測不能で、
全量を LLM コンテキストに載せるとトークン消費が爆発する。
## 方針
ツール出力を **summary常駐****contentprunable** の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<String>,
}
```
### Item::ToolResult
```rust
Item::ToolResult {
id: Option<ItemId>,
call_id: CallId,
/// 1-2行の要約。Prune 後も残る。
summary: String,
/// 詳細な出力。Prune で None に置換される。
content: Option<String>,
}
```
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<ToolOutput, ToolError>` に変更する。
```rust
#[async_trait]
pub trait Tool: Send + Sync {
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError>;
}
```
ツールが独自の summary を付けたい場合は `ToolOutput` を直接構築する。
単純なケースでは `From<String>` で自動変換できる: `Ok("result".to_string().into())`
### From\<String\> 変換
`From<String>` による自動変換:
```rust
impl From<String> 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 に追加
─── 数ターン経過 ───
Prunepre_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` enumInline/Stored | struct に置換 |
| `Content` enumText/Structured | 不要 |
| `auto_summarize` / `auto_summarize_text` / `auto_summarize_structured` | 不要 |
| `ToolOutputProcessor` trait | 不要 |
| `BlobOutputProcessor` | 不要 |
| `BlobStore` trait / `FsBlobStore` | 不要 |
| `inspect_tool.rs` | 不要 |
| Worker の `output_processor` フィールド | 不要 |