Tool Outputの仕様簡素化
This commit is contained in:
@@ -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<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 に追加
|
||||
|
||||
─── 数ターン経過 ───
|
||||
|
||||
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` フィールド | 不要 |
|
||||
@@ -292,9 +292,9 @@ impl Interceptor for ToolResultPrinterPolicy {
|
||||
.unwrap_or_else(|| info.result.tool_use_id.clone());
|
||||
|
||||
if info.result.is_error {
|
||||
println!(" Result ({}): ❌ {}", name, info.result.content);
|
||||
println!(" Result ({}): ❌ {}", name, info.result.summary);
|
||||
} else {
|
||||
println!(" Result ({}): ✅ {}", name, info.result.content);
|
||||
println!(" Result ({}): ✅ {}", name, info.result.summary);
|
||||
}
|
||||
|
||||
PostToolAction::Continue
|
||||
|
||||
@@ -183,7 +183,7 @@ impl AnthropicScheme {
|
||||
}
|
||||
|
||||
Item::ToolResult {
|
||||
call_id, output, ..
|
||||
call_id, summary, content, ..
|
||||
} => {
|
||||
// Flush pending assistant parts first
|
||||
if !pending_assistant_parts.is_empty() {
|
||||
@@ -195,9 +195,13 @@ impl AnthropicScheme {
|
||||
});
|
||||
}
|
||||
|
||||
let text = match content {
|
||||
Some(c) => format!("{summary}\n{c}"),
|
||||
None => summary.clone(),
|
||||
};
|
||||
pending_user_parts.push(AnthropicContentPart::ToolResult {
|
||||
tool_use_id: call_id.clone(),
|
||||
content: output.clone(),
|
||||
content: text,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -258,7 +258,7 @@ impl GeminiScheme {
|
||||
}
|
||||
|
||||
Item::ToolResult {
|
||||
call_id, output, ..
|
||||
call_id, summary, content, ..
|
||||
} => {
|
||||
// Flush pending model parts first
|
||||
if !pending_model_parts.is_empty() {
|
||||
@@ -268,12 +268,16 @@ impl GeminiScheme {
|
||||
});
|
||||
}
|
||||
|
||||
let text = match content {
|
||||
Some(c) => format!("{summary}\n{c}"),
|
||||
None => summary.clone(),
|
||||
};
|
||||
pending_user_parts.push(GeminiPart::FunctionResponse {
|
||||
function_response: GeminiFunctionResponse {
|
||||
name: call_id.clone(),
|
||||
response: GeminiFunctionResponseContent {
|
||||
name: call_id.clone(),
|
||||
content: Value::String(output.clone()),
|
||||
content: Value::String(text),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -212,7 +212,7 @@ impl OpenAIScheme {
|
||||
}
|
||||
|
||||
Item::ToolResult {
|
||||
call_id, output, ..
|
||||
call_id, summary, content, ..
|
||||
} => {
|
||||
// Flush pending tool calls before tool result
|
||||
self.flush_pending_assistant(
|
||||
@@ -221,9 +221,13 @@ impl OpenAIScheme {
|
||||
&mut pending_assistant_text,
|
||||
);
|
||||
|
||||
let text = match content {
|
||||
Some(c) => format!("{summary}\n{c}"),
|
||||
None => summary.clone(),
|
||||
};
|
||||
messages.push(OpenAIMessage {
|
||||
role: "tool".to_string(),
|
||||
content: Some(OpenAIContent::Text(output.clone())),
|
||||
content: Some(OpenAIContent::Text(text)),
|
||||
tool_calls: vec![],
|
||||
tool_call_id: Some(call_id.clone()),
|
||||
name: None,
|
||||
|
||||
@@ -74,8 +74,11 @@ pub enum Item {
|
||||
id: Option<ItemId>,
|
||||
/// Call ID linking to the tool call
|
||||
call_id: CallId,
|
||||
/// Output content
|
||||
output: String,
|
||||
/// Short summary (always kept in history, survives pruning)
|
||||
summary: String,
|
||||
/// Detailed output (removed by pruning when old enough)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
content: Option<String>,
|
||||
},
|
||||
|
||||
/// Reasoning/thinking item
|
||||
@@ -164,12 +167,27 @@ impl Item {
|
||||
Self::tool_call(call_id, name, arguments.to_string())
|
||||
}
|
||||
|
||||
/// Create a tool result item
|
||||
pub fn tool_result(call_id: impl Into<String>, output: impl Into<String>) -> Self {
|
||||
/// Create a tool result item with summary only (no content).
|
||||
pub fn tool_result(call_id: impl Into<String>, summary: impl Into<String>) -> Self {
|
||||
Self::ToolResult {
|
||||
id: None,
|
||||
call_id: call_id.into(),
|
||||
output: output.into(),
|
||||
summary: summary.into(),
|
||||
content: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a tool result item with summary and content.
|
||||
pub fn tool_result_with_content(
|
||||
call_id: impl Into<String>,
|
||||
summary: impl Into<String>,
|
||||
content: impl Into<String>,
|
||||
) -> Self {
|
||||
Self::ToolResult {
|
||||
id: None,
|
||||
call_id: call_id.into(),
|
||||
summary: summary.into(),
|
||||
content: Some(content.into()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+49
-211
@@ -25,199 +25,50 @@ pub enum ToolError {
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ToolOutput - Tool execution result with size-aware storage
|
||||
// ToolOutput - Tool execution result with summary + content
|
||||
// =============================================================================
|
||||
|
||||
/// Tool output size threshold in bytes.
|
||||
/// Results larger than this are automatically promoted to `Stored`.
|
||||
pub const INLINE_THRESHOLD: usize = 800;
|
||||
|
||||
/// Maximum size of auto-generated summaries in bytes.
|
||||
pub const SUMMARY_MAX_BYTES: usize = 400;
|
||||
|
||||
/// Number of lines to include from the head of text content in summaries.
|
||||
pub const SUMMARY_HEAD_LINES: usize = 5;
|
||||
|
||||
/// Number of lines to include from the tail of text content in summaries.
|
||||
pub const SUMMARY_TAIL_LINES: usize = 3;
|
||||
/// Threshold below which tool output is treated as summary-only (no content).
|
||||
/// Outputs this small don't benefit from pruning.
|
||||
pub const SUMMARY_THRESHOLD: usize = 200;
|
||||
|
||||
/// Tool execution result.
|
||||
///
|
||||
/// Small results are kept inline in conversation history.
|
||||
/// Large results are stored externally via `BlobStore`, with only
|
||||
/// a summary placed in the history. The LLM can retrieve details
|
||||
/// using the built-in `inspect` tool.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ToolOutput {
|
||||
/// Small result: placed directly into history as-is.
|
||||
Inline(String),
|
||||
/// Large result: summary goes into history, full content is stored externally.
|
||||
Stored {
|
||||
/// Concise summary shown to the LLM in conversation context.
|
||||
summary: String,
|
||||
/// Full content to be persisted in a BlobStore.
|
||||
content: Content,
|
||||
},
|
||||
}
|
||||
|
||||
impl ToolOutput {
|
||||
/// Get the string that should be placed into conversation history.
|
||||
pub fn history_text(&self) -> &str {
|
||||
match self {
|
||||
ToolOutput::Inline(s) => s,
|
||||
ToolOutput::Stored { summary, .. } => summary,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this output requires external storage.
|
||||
pub fn is_stored(&self) -> bool {
|
||||
matches!(self, ToolOutput::Stored { .. })
|
||||
}
|
||||
}
|
||||
|
||||
/// Content to be stored in a BlobStore.
|
||||
/// Every output has a mandatory `summary` (1-2 lines) that persists in
|
||||
/// conversation history even after pruning. The optional `content` carries
|
||||
/// full details and is removed by the Prune mechanism when the context
|
||||
/// grows too large.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", content = "data")]
|
||||
pub enum Content {
|
||||
/// Plain text (file contents, search results, logs, etc.)
|
||||
Text(String),
|
||||
/// Structured JSON data (API responses, query results, etc.)
|
||||
Structured(Value),
|
||||
pub struct ToolOutput {
|
||||
/// Short summary (1-2 lines). Always remains in history.
|
||||
pub summary: String,
|
||||
/// Detailed output. Removed by Prune when old enough.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
}
|
||||
|
||||
impl From<String> for ToolOutput {
|
||||
fn from(s: String) -> Self {
|
||||
if s.len() <= INLINE_THRESHOLD {
|
||||
ToolOutput::Inline(s)
|
||||
if s.len() <= SUMMARY_THRESHOLD {
|
||||
ToolOutput {
|
||||
summary: s,
|
||||
content: None,
|
||||
}
|
||||
} else {
|
||||
let summary = auto_summarize_text(&s);
|
||||
ToolOutput::Stored {
|
||||
summary,
|
||||
content: Content::Text(s),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a summary for any [`Content`] variant.
|
||||
///
|
||||
/// The blob ID prefix (`[blob:<id>]`) is NOT included here — it is
|
||||
/// prepended by the Worker after the content is stored and an ID is assigned.
|
||||
pub fn auto_summarize(content: &Content) -> String {
|
||||
match content {
|
||||
Content::Text(text) => auto_summarize_text(text),
|
||||
Content::Structured(value) => auto_summarize_structured(value),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a summary for plain text content.
|
||||
fn auto_summarize_text(text: &str) -> String {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let total = lines.len();
|
||||
|
||||
let mut summary = format!("text | {total} lines\n");
|
||||
|
||||
// Head
|
||||
summary.push_str("── head ──\n");
|
||||
for line in lines.iter().take(SUMMARY_HEAD_LINES) {
|
||||
summary.push_str(line);
|
||||
summary.push('\n');
|
||||
}
|
||||
|
||||
// Tail (only if there's content beyond head)
|
||||
if total > SUMMARY_HEAD_LINES + SUMMARY_TAIL_LINES {
|
||||
summary.push_str("── tail ──\n");
|
||||
let tail_start = total.saturating_sub(SUMMARY_TAIL_LINES);
|
||||
for line in &lines[tail_start..] {
|
||||
summary.push_str(line);
|
||||
summary.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate if summary itself is too large
|
||||
if summary.len() > SUMMARY_MAX_BYTES {
|
||||
summary.truncate(SUMMARY_MAX_BYTES);
|
||||
summary.push_str("…\n");
|
||||
}
|
||||
|
||||
summary
|
||||
}
|
||||
|
||||
/// Generate a summary for structured JSON content.
|
||||
fn auto_summarize_structured(value: &Value) -> String {
|
||||
let mut summary = match value {
|
||||
Value::Array(arr) => {
|
||||
let mut s = format!("json_array | {} entries\n", arr.len());
|
||||
// Show schema from first element
|
||||
if let Some(first) = arr.first() {
|
||||
s.push_str("── schema ──\n");
|
||||
s.push_str(&describe_value_shape(first));
|
||||
s.push('\n');
|
||||
}
|
||||
// Show first 2 entries
|
||||
s.push_str("── head ──\n");
|
||||
for item in arr.iter().take(2) {
|
||||
if let Ok(json) = serde_json::to_string(item) {
|
||||
s.push_str(&json);
|
||||
s.push('\n');
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
Value::Object(map) => {
|
||||
let mut s = format!("json_object | {} keys\n", map.len());
|
||||
s.push_str("── keys ──\n");
|
||||
for (key, val) in map.iter() {
|
||||
s.push_str(&format!("{key}: {}\n", value_type_label(val)));
|
||||
}
|
||||
s
|
||||
}
|
||||
_ => {
|
||||
// Scalar or other — just show the JSON
|
||||
format!(
|
||||
"json | {}\n",
|
||||
serde_json::to_string(value).unwrap_or_default()
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
if summary.len() > SUMMARY_MAX_BYTES {
|
||||
summary.truncate(SUMMARY_MAX_BYTES);
|
||||
summary.push_str("…\n");
|
||||
}
|
||||
|
||||
summary
|
||||
}
|
||||
|
||||
/// Describe the shape of a JSON value (for schema preview).
|
||||
fn describe_value_shape(value: &Value) -> String {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let fields: Vec<String> = map
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{k}: {}", value_type_label(v)))
|
||||
let lines = s.lines().count();
|
||||
let first_line: String = s
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.chars()
|
||||
.take(80)
|
||||
.collect();
|
||||
format!("{{ {} }}", fields.join(", "))
|
||||
}
|
||||
_ => value_type_label(value),
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable type label for a JSON value.
|
||||
fn value_type_label(value: &Value) -> String {
|
||||
match value {
|
||||
Value::Null => "null".to_string(),
|
||||
Value::Bool(_) => "bool".to_string(),
|
||||
Value::Number(_) => "number".to_string(),
|
||||
Value::String(s) => {
|
||||
if s.len() > 50 {
|
||||
format!("string({})", s.len())
|
||||
} else {
|
||||
"string".to_string()
|
||||
let summary = format!("{lines} lines | {first_line}…");
|
||||
ToolOutput {
|
||||
summary,
|
||||
content: Some(s),
|
||||
}
|
||||
}
|
||||
Value::Array(arr) => format!("array({})", arr.len()),
|
||||
Value::Object(map) => format!("object({})", map.len()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,34 +192,15 @@ pub type ToolDefinition = Arc<dyn Fn() -> (ToolMeta, Arc<dyn Tool>) + Send + Syn
|
||||
/// ```
|
||||
#[async_trait]
|
||||
pub trait Tool: Send + Sync {
|
||||
/// Execute the tool
|
||||
/// Execute the tool.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `input_json` - JSON-formatted arguments generated by LLM
|
||||
///
|
||||
/// # Returns
|
||||
/// Result string from execution. This content is returned to LLM.
|
||||
async fn execute(&self, input_json: &str) -> Result<String, ToolError>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ToolOutputProcessor - Output storage abstraction
|
||||
// =============================================================================
|
||||
|
||||
/// Processes tool output before it enters conversation history.
|
||||
///
|
||||
/// When a tool produces a large result, the processor can store the
|
||||
/// full content externally and return a summary string for the history.
|
||||
///
|
||||
/// If no processor is set on Worker, all tool outputs are used as-is (inline).
|
||||
#[async_trait]
|
||||
pub trait ToolOutputProcessor: Send + Sync {
|
||||
/// Process a tool's raw output string.
|
||||
///
|
||||
/// Returns the string that should be placed into conversation history.
|
||||
/// For small outputs, this may be the original string unchanged.
|
||||
/// For large outputs, this should be a summary with a blob reference.
|
||||
async fn process(&self, output: String) -> Result<String, ToolError>;
|
||||
/// A [`ToolOutput`] with summary and optional detailed content.
|
||||
/// For simple cases, use `From<String>`: `Ok("done".to_string().into())`
|
||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -390,33 +222,39 @@ pub struct ToolCall {
|
||||
|
||||
/// Tool execution result
|
||||
///
|
||||
/// Represents the result after tool execution.
|
||||
/// Intermediate representation between tool execution and history.
|
||||
/// Carries `summary` + optional `content` from [`ToolOutput`].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolResult {
|
||||
/// Corresponding tool call ID
|
||||
pub tool_use_id: String,
|
||||
/// Result content
|
||||
pub content: String,
|
||||
/// Short summary (always kept in history)
|
||||
pub summary: String,
|
||||
/// Detailed output (prunable)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
/// Whether this is an error
|
||||
#[serde(default)]
|
||||
pub is_error: bool,
|
||||
}
|
||||
|
||||
impl ToolResult {
|
||||
/// Create a success result
|
||||
pub fn success(tool_use_id: impl Into<String>, content: impl Into<String>) -> Self {
|
||||
/// Create a success result from a [`ToolOutput`].
|
||||
pub fn from_output(tool_use_id: impl Into<String>, output: ToolOutput) -> Self {
|
||||
Self {
|
||||
tool_use_id: tool_use_id.into(),
|
||||
content: content.into(),
|
||||
summary: output.summary,
|
||||
content: output.content,
|
||||
is_error: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an error result
|
||||
pub fn error(tool_use_id: impl Into<String>, content: impl Into<String>) -> Self {
|
||||
/// Create an error result.
|
||||
pub fn error(tool_use_id: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
tool_use_id: tool_use_id.into(),
|
||||
content: content.into(),
|
||||
summary: message.into(),
|
||||
content: None,
|
||||
is_error: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::sync::{Arc, Mutex};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::llm_client::ToolDefinition as LlmToolDefinition;
|
||||
use crate::tool::{Tool, ToolDefinition as WorkerToolDefinition, ToolMeta};
|
||||
use crate::tool::{Tool, ToolDefinition as WorkerToolDefinition, ToolMeta, ToolOutput};
|
||||
|
||||
type ToolMap = HashMap<String, (ToolMeta, Arc<dyn Tool>)>;
|
||||
|
||||
@@ -110,7 +110,7 @@ impl ToolServerHandle {
|
||||
}
|
||||
|
||||
/// Execute a tool by name.
|
||||
pub async fn call_tool(&self, name: &str, input_json: &str) -> Result<String, ToolServerError> {
|
||||
pub async fn call_tool(&self, name: &str, input_json: &str) -> Result<ToolOutput, ToolServerError> {
|
||||
let tool = {
|
||||
let guard = self.tools.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (_, tool) = guard
|
||||
@@ -180,8 +180,8 @@ mod tests {
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for EchoTool {
|
||||
async fn execute(&self, input_json: &str) -> Result<String, ToolError> {
|
||||
Ok(input_json.to_string())
|
||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
Ok(input_json.to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,7 +230,8 @@ mod tests {
|
||||
handle.flush_pending();
|
||||
|
||||
let out = handle.call_tool("echo", r#"{"x":1}"#).await.expect("call");
|
||||
assert_eq!(out, r#"{"x":1}"#);
|
||||
assert_eq!(out.summary, r#"{"x":1}"#);
|
||||
assert!(out.content.is_none());
|
||||
|
||||
let err = handle
|
||||
.call_tool("missing", "{}")
|
||||
@@ -290,8 +291,8 @@ mod tests {
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for FixedTool {
|
||||
async fn execute(&self, _input_json: &str) -> Result<String, ToolError> {
|
||||
Ok("replaced".to_string())
|
||||
async fn execute(&self, _input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
Ok("replaced".to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,8 +320,8 @@ mod tests {
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ConstTool {
|
||||
async fn execute(&self, _input_json: &str) -> Result<String, ToolError> {
|
||||
Ok("const".to_string())
|
||||
async fn execute(&self, _input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
Ok("const".to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,7 +336,7 @@ mod tests {
|
||||
handle.replace(replacement).expect("replace");
|
||||
|
||||
let out = handle.call_tool("echo", "{}").await.expect("call");
|
||||
assert_eq!(out, "const");
|
||||
assert_eq!(out.summary, "const");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -352,10 +353,10 @@ mod tests {
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for GatedTool {
|
||||
async fn execute(&self, _input_json: &str) -> Result<String, ToolError> {
|
||||
async fn execute(&self, _input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
self.started.notify_one();
|
||||
self.finish.notified().await;
|
||||
Ok("done".to_string())
|
||||
Ok("done".to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,7 +389,7 @@ mod tests {
|
||||
// Let the in-flight call finish.
|
||||
finish.notify_one();
|
||||
let result = call.await.expect("join");
|
||||
assert_eq!(result.expect("call"), "done");
|
||||
assert_eq!(result.expect("call").summary, "done");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -405,10 +406,10 @@ mod tests {
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for OldTool {
|
||||
async fn execute(&self, _input_json: &str) -> Result<String, ToolError> {
|
||||
async fn execute(&self, _input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
self.started.notify_one();
|
||||
self.finish.notified().await;
|
||||
Ok("old".to_string())
|
||||
Ok("old".to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,8 +440,8 @@ mod tests {
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for NewTool {
|
||||
async fn execute(&self, _input_json: &str) -> Result<String, ToolError> {
|
||||
Ok("new".to_string())
|
||||
async fn execute(&self, _input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
Ok("new".to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,11 +459,11 @@ mod tests {
|
||||
// Let the old in-flight call finish — it should return "old".
|
||||
finish.notify_one();
|
||||
let result = call.await.expect("join");
|
||||
assert_eq!(result.expect("call"), "old");
|
||||
assert_eq!(result.expect("call").summary, "old");
|
||||
|
||||
// New calls use the replacement.
|
||||
let out = handle.call_tool("t", "{}").await.expect("call");
|
||||
assert_eq!(out, "new");
|
||||
assert_eq!(out.summary, "new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures::StreamExt;
|
||||
use tokio::sync::mpsc;
|
||||
@@ -21,7 +20,7 @@ use crate::{
|
||||
handler::{ErrorKind, StatusKind, ToolUseBlockStart, UsageKind},
|
||||
timeline::{TextBlockCollector, Timeline, ToolCallCollector},
|
||||
timeline::event::{ErrorEvent, StatusEvent, UsageEvent},
|
||||
tool::{ToolCall, ToolDefinition as WorkerToolDefinition, ToolError, ToolOutputProcessor, ToolResult},
|
||||
tool::{ToolCall, ToolDefinition as WorkerToolDefinition, ToolError, ToolResult},
|
||||
tool_server::{ToolServer, ToolServerHandle},
|
||||
};
|
||||
|
||||
@@ -154,8 +153,6 @@ pub struct Worker<C: LlmClient, S: WorkerState = Mutable> {
|
||||
request_config: RequestConfig,
|
||||
/// Whether the previous run was interrupted
|
||||
last_run_interrupted: bool,
|
||||
/// Optional processor for large tool outputs (stores externally, returns summary)
|
||||
output_processor: Option<Arc<dyn ToolOutputProcessor>>,
|
||||
/// Cancel notification channel (for interrupting execution)
|
||||
cancel_tx: mpsc::Sender<()>,
|
||||
cancel_rx: mpsc::Receiver<()>,
|
||||
@@ -610,7 +607,7 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
|
||||
async move {
|
||||
let input_json = serde_json::to_string(&tool_call.input).unwrap_or_default();
|
||||
match tool_server.call_tool(&tool_call.name, &input_json).await {
|
||||
Ok(content) => ToolResult::success(&tool_call.id, content),
|
||||
Ok(output) => ToolResult::from_output(&tool_call.id, output),
|
||||
Err(e) => ToolResult::error(&tool_call.id, e.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -630,20 +627,6 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
|
||||
}
|
||||
};
|
||||
|
||||
// Phase 2.5: Apply output processor (store large results externally)
|
||||
if let Some(ref processor) = self.output_processor {
|
||||
for tool_result in &mut results {
|
||||
if !tool_result.is_error {
|
||||
match processor.process(tool_result.content.clone()).await {
|
||||
Ok(processed) => tool_result.content = processed,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "Output processor failed, keeping original content");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Apply post_tool_call interceptor
|
||||
for tool_result in &mut results {
|
||||
if let Some((tool_call, meta, tool)) = call_info_map.get(&tool_result.tool_use_id) {
|
||||
@@ -835,8 +818,18 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
|
||||
}
|
||||
Ok(ToolExecutionResult::Completed(results)) => {
|
||||
for result in results {
|
||||
self.history
|
||||
.push(Item::tool_result(&result.tool_use_id, &result.content));
|
||||
if let Some(ref content) = result.content {
|
||||
self.history.push(Item::tool_result_with_content(
|
||||
&result.tool_use_id,
|
||||
&result.summary,
|
||||
content,
|
||||
));
|
||||
} else {
|
||||
self.history.push(Item::tool_result(
|
||||
&result.tool_use_id,
|
||||
&result.summary,
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
@@ -878,7 +871,6 @@ impl<C: LlmClient> Worker<C, Mutable> {
|
||||
turn_end_cbs: Vec::new(),
|
||||
request_config: RequestConfig::default(),
|
||||
last_run_interrupted: false,
|
||||
output_processor: None,
|
||||
cancel_tx,
|
||||
cancel_rx,
|
||||
_state: PhantomData,
|
||||
@@ -1063,14 +1055,6 @@ impl<C: LlmClient> Worker<C, Mutable> {
|
||||
self.last_run_interrupted = interrupted;
|
||||
}
|
||||
|
||||
/// Set a tool output processor for handling large tool results.
|
||||
///
|
||||
/// When set, tool execution results are passed through this processor
|
||||
/// before being placed into conversation history.
|
||||
pub fn set_output_processor(&mut self, processor: Arc<dyn ToolOutputProcessor>) {
|
||||
self.output_processor = Some(processor);
|
||||
}
|
||||
|
||||
/// Apply configuration (reserved for future extensions)
|
||||
#[allow(dead_code)]
|
||||
pub fn config(self, _config: WorkerConfig) -> Self {
|
||||
@@ -1134,7 +1118,7 @@ impl<C: LlmClient> Worker<C, Mutable> {
|
||||
turn_end_cbs: self.turn_end_cbs,
|
||||
request_config: self.request_config,
|
||||
last_run_interrupted: self.last_run_interrupted,
|
||||
output_processor: self.output_processor,
|
||||
|
||||
cancel_tx: self.cancel_tx,
|
||||
cancel_rx: self.cancel_rx,
|
||||
_state: PhantomData,
|
||||
@@ -1204,7 +1188,7 @@ impl<C: LlmClient> Worker<C, Locked> {
|
||||
turn_end_cbs: self.turn_end_cbs,
|
||||
request_config: self.request_config,
|
||||
last_run_interrupted: self.last_run_interrupted,
|
||||
output_processor: self.output_processor,
|
||||
|
||||
cancel_tx: self.cancel_tx,
|
||||
cancel_rx: self.cancel_rx,
|
||||
_state: PhantomData,
|
||||
|
||||
@@ -10,7 +10,7 @@ use async_trait::async_trait;
|
||||
use llm_worker::Worker;
|
||||
use llm_worker::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||
use llm_worker::interceptor::{Interceptor, PostToolAction, PreToolAction, ToolCallInfo, ToolResultInfo};
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta};
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
|
||||
mod common;
|
||||
use common::MockLlmClient;
|
||||
@@ -57,10 +57,10 @@ impl SlowTool {
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SlowTool {
|
||||
async fn execute(&self, _input_json: &str) -> Result<String, ToolError> {
|
||||
async fn execute(&self, _input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
self.call_count.fetch_add(1, Ordering::SeqCst);
|
||||
tokio::time::sleep(Duration::from_millis(self.delay_ms)).await;
|
||||
Ok(format!("Completed after {}ms", self.delay_ms))
|
||||
Ok(format!("Completed after {}ms", self.delay_ms).into())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,8 +218,8 @@ async fn test_post_tool_call_modification() {
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SimpleTool {
|
||||
async fn execute(&self, _: &str) -> Result<String, ToolError> {
|
||||
Ok("Original Result".to_string())
|
||||
async fn execute(&self, _: &str) -> Result<ToolOutput, ToolError> {
|
||||
Ok("Original Result".to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,8 +242,8 @@ async fn test_post_tool_call_modification() {
|
||||
#[async_trait]
|
||||
impl Interceptor for ModifyingPolicy {
|
||||
async fn post_tool_call(&self, info: &mut ToolResultInfo) -> PostToolAction {
|
||||
info.result.content = format!("[Modified] {}", info.result.content);
|
||||
*self.modified_content.lock().unwrap() = Some(info.result.content.clone());
|
||||
info.result.summary = format!("[Modified] {}", info.result.summary);
|
||||
*self.modified_content.lock().unwrap() = Some(info.result.summary.clone());
|
||||
PostToolAction::Continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,8 +77,8 @@ async fn test_basic_tool_generation() {
|
||||
let result = tool.execute(r#"{"message": "World"}"#).await;
|
||||
assert!(result.is_ok(), "Should execute successfully");
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("Hello"), "Output should contain prefix");
|
||||
assert!(output.contains("World"), "Output should contain message");
|
||||
assert!(output.summary.contains("Hello"), "Output should contain prefix");
|
||||
assert!(output.summary.contains("World"), "Output should contain message");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -94,7 +94,7 @@ async fn test_multiple_arguments() {
|
||||
let result = tool.execute(r#"{"a": 10, "b": 20}"#).await;
|
||||
assert!(result.is_ok());
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("30"), "Should contain sum: {}", output);
|
||||
assert!(output.summary.contains("30"), "Should contain sum: {:?}", output);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -112,8 +112,8 @@ async fn test_no_arguments() {
|
||||
assert!(result.is_ok());
|
||||
let output = result.unwrap();
|
||||
assert!(
|
||||
output.contains("TestPrefix"),
|
||||
"Should contain prefix: {}",
|
||||
output.summary.contains("TestPrefix"),
|
||||
"Should contain prefix: {:?}",
|
||||
output
|
||||
);
|
||||
}
|
||||
@@ -168,7 +168,7 @@ async fn test_result_return_type_success() {
|
||||
let result = tool.execute(r#"{"value": 42}"#).await;
|
||||
assert!(result.is_ok(), "Should succeed for positive value");
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("Valid"), "Should contain Valid: {}", output);
|
||||
assert!(output.summary.contains("Valid"), "Should contain Valid: {:?}", output);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -12,7 +12,7 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use async_trait::async_trait;
|
||||
use common::MockLlmClient;
|
||||
use llm_worker::Worker;
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta};
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
|
||||
/// Fixture directory path
|
||||
fn fixtures_dir() -> std::path::PathBuf {
|
||||
@@ -58,7 +58,7 @@ impl MockWeatherTool {
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for MockWeatherTool {
|
||||
async fn execute(&self, input_json: &str) -> Result<String, ToolError> {
|
||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
self.call_count.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
// Parse input
|
||||
@@ -68,7 +68,7 @@ impl Tool for MockWeatherTool {
|
||||
let city = input["city"].as_str().unwrap_or("Unknown");
|
||||
|
||||
// Return mock response
|
||||
Ok(format!("Weather in {}: Sunny, 22°C", city))
|
||||
Ok(format!("Weather in {}: Sunny, 22°C", city).into())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ use common::MockLlmClient;
|
||||
use llm_worker::Item;
|
||||
use llm_worker::{Worker, WorkerError};
|
||||
use llm_worker::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta};
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
|
||||
// =============================================================================
|
||||
// Mutable State Tests
|
||||
@@ -134,9 +134,9 @@ impl CountingTool {
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for CountingTool {
|
||||
async fn execute(&self, _input_json: &str) -> Result<String, ToolError> {
|
||||
async fn execute(&self, _input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(format!("{}-ok", self.name))
|
||||
Ok(format!("{}-ok", self.name).into())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user