yoi/tickets/tool-output-design.md

5.1 KiB
Raw Blame History

ツール出力の設計

課題

ツール実行結果(ファイル内容、検索結果等)はサイズが予測不能で、 全量を LLM コンテキストに載せるとトークン消費が爆発する。

方針

ツール出力を summary常駐contentprunable の2フィールドに分離する。

  • summary: 1-2行。常に history に残る。Prune 後もこれだけで「何をしたか」がわかる
  • content: 詳細な出力。一定閾値まで。Prune で消える

巨大な出力(大量の grep 結果、巨大ファイル等)はフレームワークの責務外。 ツール側がファイルに書き出し、content に見取り図を置く。

データ型

ToolOutput

/// ツール実行結果。
///
/// 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

Item::ToolResult {
    id: Option<ItemId>,
    call_id: CallId,
    /// 1-2行の要約。Prune 後も残る。
    summary: String,
    /// 詳細な出力。Prune で None に置換される。
    content: Option<String>,
}

LLM への送信時は summary + content を結合して単一文字列にする。 content が None の場合は summary のみ。

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> に変更する。

#[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> による自動変換:

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 フィールド 不要