Skip to main content

vtcode_core/tools/
result.rs

1//! Split tool results: Dual-channel output for LLM and UI
2//!
3//! Implements Phase 4 of pi-coding-agent integration:
4//! - LLM content: Concise summaries optimized for token efficiency
5//! - UI content: Rich output with full details for user display
6//!
7//! Expected savings: 20-30% on tool-heavy sessions (97% on tool output tokens)
8
9use hashbrown::HashMap;
10use serde::{Deserialize, Serialize};
11use std::path::PathBuf;
12
13#[cfg(test)]
14use crate::config::constants::tools;
15use crate::utils::tokens::estimate_tokens;
16
17/// Result from tool execution with dual-channel output
18///
19/// Tools return two versions of their output:
20/// 1. `llm_content` - Concise summary for model context (token-optimized)
21/// 2. `ui_content` - Rich output for user display (full details)
22///
23/// This enables significant token savings while preserving user experience.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ToolResult {
26    /// Tool name that produced this result
27    pub tool_name: String,
28
29    /// Concise summary for LLM context (token-optimized)
30    ///
31    /// Example: "Found 127 matches in 15 files. Key: src/tools/grep.rs (3), src/tools/list.rs (1)"
32    /// vs full output which might be 2,500 tokens
33    pub llm_content: String,
34
35    /// Rich output for UI display (full details)
36    ///
37    /// Can include ANSI codes, formatting, full listings, etc.
38    /// Not sent to LLM, only displayed to user
39    pub ui_content: String,
40
41    /// Whether the tool execution succeeded
42    pub success: bool,
43
44    /// Error message if execution failed
45    pub error: Option<String>,
46
47    /// Structured metadata for both channels
48    pub metadata: ToolMetadata,
49}
50
51/// Metadata accompanying tool results
52///
53/// Provides structured data that can be used by both LLM and UI
54/// without being embedded in content strings
55#[derive(Debug, Clone, Default, Serialize, Deserialize)]
56pub struct ToolMetadata {
57    /// File paths referenced by this tool (for UI linking, LLM context)
58    pub files: Vec<PathBuf>,
59
60    /// Line numbers referenced (for UI jump-to-line)
61    pub lines: Vec<usize>,
62
63    /// Key-value pairs for structured data
64    ///
65    /// Examples:
66    /// - match_count: 127
67    /// - files_searched: 50
68    /// - execution_time_ms: 234
69    pub data: HashMap<String, serde_json::Value>,
70
71    /// Token counts for observability
72    pub token_counts: TokenCounts,
73}
74
75/// Token counting for split tool results
76#[derive(Debug, Clone, Default, Serialize, Deserialize)]
77pub struct TokenCounts {
78    /// Tokens in LLM content (what we send to model)
79    pub llm_tokens: usize,
80
81    /// Tokens in UI content (what we DON'T send to model)
82    pub ui_tokens: usize,
83
84    /// Tokens saved by splitting (ui_tokens - llm_tokens)
85    pub savings_tokens: usize,
86
87    /// Percentage saved (0-100)
88    pub savings_percent: f32,
89}
90
91impl ToolResult {
92    /// Create a new tool result with dual content
93    pub fn new(
94        tool_name: impl Into<String>,
95        llm_content: impl Into<String>,
96        ui_content: impl Into<String>,
97    ) -> Self {
98        let llm_str = llm_content.into();
99        let ui_str = ui_content.into();
100
101        let llm_tokens = estimate_tokens(&llm_str);
102        let ui_tokens = estimate_tokens(&ui_str);
103        let savings = ui_tokens.saturating_sub(llm_tokens);
104        let savings_pct = if ui_tokens > 0 {
105            (savings as f32 / ui_tokens as f32) * 100.0
106        } else {
107            0.0
108        };
109
110        Self {
111            tool_name: tool_name.into(),
112            llm_content: llm_str,
113            ui_content: ui_str,
114            success: true,
115            error: None,
116            metadata: ToolMetadata {
117                token_counts: TokenCounts {
118                    llm_tokens,
119                    ui_tokens,
120                    savings_tokens: savings,
121                    savings_percent: savings_pct,
122                },
123                ..Default::default()
124            },
125        }
126    }
127
128    /// Create an error result
129    pub fn error(tool_name: impl Into<String>, error: impl Into<String>) -> Self {
130        let error_msg = error.into();
131        Self {
132            tool_name: tool_name.into(),
133            llm_content: format!("Tool failed: {}", error_msg),
134            ui_content: format!("Error: {}", error_msg),
135            success: false,
136            error: Some(error_msg),
137            metadata: ToolMetadata::default(),
138        }
139    }
140
141    /// Create a simple result with same content for both channels
142    ///
143    /// Use this for backward compatibility or when splitting doesn't make sense
144    pub fn simple(tool_name: impl Into<String>, content: impl Into<String>) -> Self {
145        let content_str = content.into();
146        Self::new(tool_name, content_str.clone(), content_str)
147    }
148
149    /// Add metadata to the result
150    pub fn with_metadata(mut self, metadata: ToolMetadata) -> Self {
151        // Preserve token counts from construction
152        let token_counts = std::mem::take(&mut self.metadata.token_counts);
153        self.metadata = metadata;
154        self.metadata.token_counts = token_counts;
155        self
156    }
157
158    /// Add file references to metadata
159    pub fn with_files(mut self, files: Vec<PathBuf>) -> Self {
160        self.metadata.files = files;
161        self
162    }
163
164    /// Add data to metadata
165    pub fn with_data(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
166        self.metadata.data.insert(key.into(), value);
167        self
168    }
169
170    /// Get token savings summary for logging
171    pub fn savings_summary(&self) -> String {
172        let counts = &self.metadata.token_counts;
173        format!(
174            "{} → {} tokens ({:.1}% saved)",
175            counts.ui_tokens, counts.llm_tokens, counts.savings_percent
176        )
177    }
178
179    /// Check if this result has significant savings (>50%)
180    pub fn has_significant_savings(&self) -> bool {
181        self.metadata.token_counts.savings_percent > 50.0
182    }
183}
184
185/// Builder for ToolMetadata
186pub struct ToolMetadataBuilder {
187    files: Vec<PathBuf>,
188    lines: Vec<usize>,
189    data: HashMap<String, serde_json::Value>,
190}
191
192impl ToolMetadataBuilder {
193    pub fn new() -> Self {
194        Self {
195            files: Vec::new(),
196            lines: Vec::new(),
197            data: HashMap::new(),
198        }
199    }
200
201    pub fn file(mut self, path: PathBuf) -> Self {
202        self.files.push(path);
203        self
204    }
205
206    pub fn files(mut self, paths: Vec<PathBuf>) -> Self {
207        self.files.extend(paths);
208        self
209    }
210
211    pub fn line(mut self, line: usize) -> Self {
212        self.lines.push(line);
213        self
214    }
215
216    pub fn lines(mut self, lines: Vec<usize>) -> Self {
217        self.lines.extend(lines);
218        self
219    }
220
221    pub fn data(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
222        self.data.insert(key.into(), value);
223        self
224    }
225
226    pub fn build(self) -> ToolMetadata {
227        ToolMetadata {
228            files: self.files,
229            lines: self.lines,
230            data: self.data,
231            token_counts: TokenCounts::default(), // Will be filled by ToolResult
232        }
233    }
234}
235
236impl Default for ToolMetadataBuilder {
237    fn default() -> Self {
238        Self::new()
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn test_tool_result_creation() {
248        let result = ToolResult::new(
249            tools::GREP_FILE,
250            "Found 127 matches in 15 files",
251            "Very long output with 127 full match listings...",
252        );
253
254        assert_eq!(result.tool_name, tools::GREP_FILE);
255        assert!(result.success);
256        assert!(result.error.is_none());
257        assert!(result.metadata.token_counts.llm_tokens > 0);
258        assert!(result.metadata.token_counts.ui_tokens > 0);
259        assert!(result.metadata.token_counts.savings_tokens > 0);
260    }
261
262    #[test]
263    fn test_error_result() {
264        let result = ToolResult::error(tools::GREP_FILE, "Pattern invalid");
265
266        assert_eq!(result.tool_name, tools::GREP_FILE);
267        assert!(!result.success);
268        assert_eq!(result.error, Some("Pattern invalid".to_string()));
269        assert!(result.llm_content.contains("failed"));
270    }
271
272    #[test]
273    fn test_simple_result() {
274        let result = ToolResult::simple("test_tool", "Same content");
275
276        assert_eq!(result.llm_content, result.ui_content);
277        assert_eq!(result.metadata.token_counts.savings_tokens, 0);
278    }
279
280    #[test]
281    fn test_token_estimation() {
282        let text = "Hello world";
283        let tokens = estimate_tokens(text);
284        // "Hello world" = 11 chars / 4 ≈ 3 tokens
285        assert_eq!(tokens, 3);
286
287        let long_text = "a".repeat(1000);
288        let long_tokens = estimate_tokens(&long_text);
289        // 1000 chars / 4 = 250 tokens
290        assert_eq!(long_tokens, 250);
291    }
292
293    #[test]
294    fn test_metadata_builder() {
295        let metadata = ToolMetadataBuilder::new()
296            .file(PathBuf::from("src/main.rs"))
297            .file(PathBuf::from("src/lib.rs"))
298            .line(42)
299            .line(100)
300            .data("match_count", serde_json::json!(127))
301            .data("files_searched", serde_json::json!(50))
302            .build();
303
304        assert_eq!(metadata.files.len(), 2);
305        assert_eq!(metadata.lines.len(), 2);
306        assert_eq!(metadata.data.len(), 2);
307        assert_eq!(metadata.data["match_count"], 127);
308    }
309
310    #[test]
311    fn test_with_methods() {
312        let result = ToolResult::new("test", "llm", "ui")
313            .with_files(vec![PathBuf::from("test.rs")])
314            .with_data("key", serde_json::json!("value"));
315
316        assert_eq!(result.metadata.files.len(), 1);
317        assert_eq!(result.metadata.data["key"], "value");
318    }
319
320    #[test]
321    fn test_savings_calculation() {
322        let result = ToolResult::new(
323            "grep",
324            "Short summary",  // ~4 tokens
325            "a".repeat(1000), // ~250 tokens
326        );
327
328        assert!(result.metadata.token_counts.savings_tokens > 200);
329        assert!(result.metadata.token_counts.savings_percent > 90.0);
330        assert!(result.has_significant_savings());
331    }
332
333    #[test]
334    fn test_savings_summary() {
335        let result = ToolResult::new("grep", "Short", "Long content here");
336
337        let summary = result.savings_summary();
338        assert!(summary.contains("→"));
339        assert!(summary.contains("tokens"));
340        assert!(summary.contains("%"));
341    }
342}