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(tool_name: impl Into<String>, llm_content: impl Into<String>, ui_content: impl Into<String>) -> Self {
94        let llm_str = llm_content.into();
95        let ui_str = ui_content.into();
96
97        let llm_tokens = estimate_tokens(&llm_str);
98        let ui_tokens = estimate_tokens(&ui_str);
99        let savings = ui_tokens.saturating_sub(llm_tokens);
100        let savings_pct = if ui_tokens > 0 {
101            (savings as f32 / ui_tokens as f32) * 100.0
102        } else {
103            0.0
104        };
105
106        Self {
107            tool_name: tool_name.into(),
108            llm_content: llm_str,
109            ui_content: ui_str,
110            success: true,
111            error: None,
112            metadata: ToolMetadata {
113                token_counts: TokenCounts {
114                    llm_tokens,
115                    ui_tokens,
116                    savings_tokens: savings,
117                    savings_percent: savings_pct,
118                },
119                ..Default::default()
120            },
121        }
122    }
123
124    /// Create an error result
125    pub fn error(tool_name: impl Into<String>, error: impl Into<String>) -> Self {
126        let error_msg = error.into();
127        Self {
128            tool_name: tool_name.into(),
129            llm_content: format!("Tool failed: {error_msg}"),
130            ui_content: format!("Error: {error_msg}"),
131            success: false,
132            error: Some(error_msg),
133            metadata: ToolMetadata::default(),
134        }
135    }
136
137    /// Create a simple result with same content for both channels
138    ///
139    /// Use this for backward compatibility or when splitting doesn't make sense
140    pub fn simple(tool_name: impl Into<String>, content: impl Into<String>) -> Self {
141        let content_str = content.into();
142        Self::new(tool_name, content_str.clone(), content_str)
143    }
144
145    /// Add metadata to the result
146    pub fn with_metadata(mut self, metadata: ToolMetadata) -> Self {
147        // Preserve token counts from construction
148        let token_counts = std::mem::take(&mut self.metadata.token_counts);
149        self.metadata = metadata;
150        self.metadata.token_counts = token_counts;
151        self
152    }
153
154    /// Add file references to metadata
155    pub fn with_files(mut self, files: Vec<PathBuf>) -> Self {
156        self.metadata.files = files;
157        self
158    }
159
160    /// Add data to metadata
161    pub fn with_data(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
162        self.metadata.data.insert(key.into(), value);
163        self
164    }
165
166    /// Get token savings summary for logging
167    pub fn savings_summary(&self) -> String {
168        let counts = &self.metadata.token_counts;
169        format!("{} → {} tokens ({:.1}% saved)", counts.ui_tokens, counts.llm_tokens, counts.savings_percent)
170    }
171
172    /// Check if this result has significant savings (>50%)
173    pub fn has_significant_savings(&self) -> bool {
174        self.metadata.token_counts.savings_percent > 50.0
175    }
176}
177
178/// Builder for ToolMetadata
179pub struct ToolMetadataBuilder {
180    files: Vec<PathBuf>,
181    lines: Vec<usize>,
182    data: HashMap<String, serde_json::Value>,
183}
184
185impl ToolMetadataBuilder {
186    pub fn new() -> Self {
187        Self {
188            files: Vec::new(),
189            lines: Vec::new(),
190            data: HashMap::new(),
191        }
192    }
193
194    pub fn file(mut self, path: PathBuf) -> Self {
195        self.files.push(path);
196        self
197    }
198
199    pub fn files(mut self, paths: Vec<PathBuf>) -> Self {
200        self.files.extend(paths);
201        self
202    }
203
204    pub fn line(mut self, line: usize) -> Self {
205        self.lines.push(line);
206        self
207    }
208
209    pub fn lines(mut self, lines: Vec<usize>) -> Self {
210        self.lines.extend(lines);
211        self
212    }
213
214    pub fn data(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
215        self.data.insert(key.into(), value);
216        self
217    }
218
219    pub fn build(self) -> ToolMetadata {
220        ToolMetadata {
221            files: self.files,
222            lines: self.lines,
223            data: self.data,
224            token_counts: TokenCounts::default(), // Will be filled by ToolResult
225        }
226    }
227}
228
229impl Default for ToolMetadataBuilder {
230    fn default() -> Self {
231        Self::new()
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn test_tool_result_creation() {
241        let result = ToolResult::new(
242            tools::GREP_FILE,
243            "Found 127 matches in 15 files",
244            "Very long output with 127 full match listings...",
245        );
246
247        assert_eq!(result.tool_name, tools::GREP_FILE);
248        assert!(result.success);
249        assert!(result.error.is_none());
250        assert!(result.metadata.token_counts.llm_tokens > 0);
251        assert!(result.metadata.token_counts.ui_tokens > 0);
252        assert!(result.metadata.token_counts.savings_tokens > 0);
253    }
254
255    #[test]
256    fn test_error_result() {
257        let result = ToolResult::error(tools::GREP_FILE, "Pattern invalid");
258
259        assert_eq!(result.tool_name, tools::GREP_FILE);
260        assert!(!result.success);
261        assert_eq!(result.error, Some("Pattern invalid".to_string()));
262        assert!(result.llm_content.contains("failed"));
263    }
264
265    #[test]
266    fn test_simple_result() {
267        let result = ToolResult::simple("test_tool", "Same content");
268
269        assert_eq!(result.llm_content, result.ui_content);
270        assert_eq!(result.metadata.token_counts.savings_tokens, 0);
271    }
272
273    #[test]
274    fn test_token_estimation() {
275        let text = "Hello world";
276        let tokens = estimate_tokens(text);
277        // tiktoken cl100k_base BPE tokenizes "Hello world" as 2 tokens
278        assert_eq!(tokens, 2);
279
280        let long_text = "a".repeat(1000);
281        let long_tokens = estimate_tokens(&long_text);
282        // Repeated single chars: ~8 chars/token for 'a' in cl100k_base
283        assert_eq!(long_tokens, 125);
284    }
285
286    #[test]
287    fn test_metadata_builder() {
288        let metadata = ToolMetadataBuilder::new()
289            .file(PathBuf::from("src/main.rs"))
290            .file(PathBuf::from("src/lib.rs"))
291            .line(42)
292            .line(100)
293            .data("match_count", serde_json::json!(127))
294            .data("files_searched", serde_json::json!(50))
295            .build();
296
297        assert_eq!(metadata.files.len(), 2);
298        assert_eq!(metadata.lines.len(), 2);
299        assert_eq!(metadata.data.len(), 2);
300        assert_eq!(metadata.data["match_count"], 127);
301    }
302
303    #[test]
304    fn test_with_methods() {
305        let result = ToolResult::new("test", "llm", "ui")
306            .with_files(vec![PathBuf::from("test.rs")])
307            .with_data("key", serde_json::json!("value"));
308
309        assert_eq!(result.metadata.files.len(), 1);
310        assert_eq!(result.metadata.data["key"], "value");
311    }
312
313    #[test]
314    fn test_savings_calculation() {
315        let result = ToolResult::new(
316            "grep",
317            "Short summary",  // ~2 tokens
318            "a".repeat(1000), // ~125 tokens (repeated chars: ~8 chars/token)
319        );
320
321        assert!(result.metadata.token_counts.savings_tokens > 100);
322        assert!(result.metadata.token_counts.savings_percent > 90.0);
323        assert!(result.has_significant_savings());
324    }
325
326    #[test]
327    fn test_savings_summary() {
328        let result = ToolResult::new("grep", "Short", "Long content here");
329
330        let summary = result.savings_summary();
331        assert!(summary.contains("→"));
332        assert!(summary.contains("tokens"));
333        assert!(summary.contains("%"));
334    }
335}