steer_tools/
result.rs

1use crate::{
2    error::ToolError,
3    tools::todo::{TodoItem, TodoWriteFileOperation},
4};
5use serde::{Deserialize, Serialize};
6
7/// Core enum for all tool results
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub enum ToolResult {
10    // One variant per built-in tool
11    Search(SearchResult),     // grep / astgrep
12    FileList(FileListResult), // ls / glob
13    FileContent(FileContentResult),
14    Edit(EditResult),
15    Bash(BashResult),
16    Glob(GlobResult),
17    TodoRead(TodoListResult),
18    TodoWrite(TodoWriteResult),
19    Fetch(FetchResult),
20    Agent(AgentResult),
21
22    // Unknown or remote (MCP) tool payload
23    External(ExternalResult),
24
25    // Failure (any tool)
26    Error(ToolError),
27}
28
29/// Result for the fetch tool
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct FetchResult {
32    pub url: String,
33    pub content: String,
34}
35
36/// Result for the dispatch_agent tool
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct AgentResult {
39    pub content: String,
40}
41
42/// Result for external/MCP tools
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct ExternalResult {
45    pub tool_name: String, // name reported by the MCP server
46    pub payload: String,   // raw, opaque blob (usually JSON or text)
47}
48
49/// Result for grep-like search tools
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct SearchResult {
52    pub matches: Vec<SearchMatch>,
53    pub total_files_searched: usize,
54    pub search_completed: bool,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct SearchMatch {
59    pub file_path: String,
60    pub line_number: usize,
61    pub line_content: String,
62    pub column_range: Option<(usize, usize)>,
63}
64
65/// Result for file listing operations
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct FileListResult {
68    pub entries: Vec<FileEntry>,
69    pub base_path: String,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct FileEntry {
74    pub path: String,
75    pub is_directory: bool,
76    pub size: Option<u64>,
77    pub permissions: Option<String>,
78}
79
80/// Result for file content viewing
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct FileContentResult {
83    pub content: String,
84    pub file_path: String,
85    pub line_count: usize,
86    pub truncated: bool,
87}
88
89/// Result for edit operations
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct EditResult {
92    pub file_path: String,
93    pub changes_made: usize,
94    pub file_created: bool,
95    pub old_content: Option<String>,
96    pub new_content: Option<String>,
97}
98
99/// Result for bash command execution
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct BashResult {
102    pub stdout: String,
103    pub stderr: String,
104    pub exit_code: i32,
105    pub command: String,
106}
107
108/// Result for glob pattern matching
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct GlobResult {
111    pub matches: Vec<String>,
112    pub pattern: String,
113}
114
115/// Result for todo operations
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct TodoListResult {
118    pub todos: Vec<TodoItem>,
119}
120/// Result for todo write operations
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct TodoWriteResult {
123    pub todos: Vec<TodoItem>,
124    pub operation: TodoWriteFileOperation,
125}
126
127// Newtype wrappers to avoid conflicting From impls
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct MultiEditResult(pub EditResult);
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct ReplaceResult(pub EditResult);
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct AstGrepResult(pub SearchResult);
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct GrepResult(pub SearchResult);
136
137// Trait for typed tool outputs
138pub trait ToolOutput: Serialize + Send + Sync + 'static {}
139
140// Implement ToolOutput for all result types
141impl ToolOutput for SearchResult {}
142impl ToolOutput for GrepResult {}
143impl ToolOutput for FileListResult {}
144impl ToolOutput for FileContentResult {}
145impl ToolOutput for EditResult {}
146impl ToolOutput for BashResult {}
147impl ToolOutput for GlobResult {}
148impl ToolOutput for TodoListResult {}
149impl ToolOutput for TodoWriteResult {}
150impl ToolOutput for MultiEditResult {}
151impl ToolOutput for ReplaceResult {}
152impl ToolOutput for AstGrepResult {}
153impl ToolOutput for ExternalResult {}
154impl ToolOutput for FetchResult {}
155impl ToolOutput for AgentResult {}
156impl ToolOutput for ToolResult {}
157
158// Manual From implementations only for types not generated by the macro
159impl From<ExternalResult> for ToolResult {
160    fn from(r: ExternalResult) -> Self {
161        Self::External(r)
162    }
163}
164
165impl From<ToolError> for ToolResult {
166    fn from(e: ToolError) -> Self {
167        Self::Error(e)
168    }
169}
170
171impl ToolResult {
172    /// Format the result for LLM consumption
173    pub fn llm_format(&self) -> String {
174        match self {
175            ToolResult::Search(r) => {
176                if r.matches.is_empty() {
177                    "No matches found.".to_string()
178                } else {
179                    let mut output = Vec::new();
180                    let mut current_file = "";
181
182                    for match_item in &r.matches {
183                        if match_item.file_path != current_file {
184                            if !output.is_empty() {
185                                output.push("".to_string());
186                            }
187                            current_file = &match_item.file_path;
188                        }
189                        output.push(format!(
190                            "{}:{}: {}",
191                            match_item.file_path, match_item.line_number, match_item.line_content
192                        ));
193                    }
194
195                    output.join("\n")
196                }
197            }
198            ToolResult::FileList(r) => {
199                if r.entries.is_empty() {
200                    format!("No entries found in {}", r.base_path)
201                } else {
202                    let mut lines = Vec::new();
203                    for entry in &r.entries {
204                        let type_indicator = if entry.is_directory { "/" } else { "" };
205                        let size_str = entry.size.map(|s| format!(" ({s})")).unwrap_or_default();
206                        lines.push(format!("{}{}{}", entry.path, type_indicator, size_str));
207                    }
208                    lines.join("\n")
209                }
210            }
211            ToolResult::FileContent(r) => r.content.clone(),
212            ToolResult::Edit(r) => {
213                if r.file_created {
214                    format!("Successfully created {}", r.file_path)
215                } else {
216                    format!(
217                        "Successfully edited {}: {} change(s) made",
218                        r.file_path, r.changes_made
219                    )
220                }
221            }
222            ToolResult::Bash(r) => {
223                // Helper to truncate long outputs
224                fn truncate_output(s: &str, max_chars: usize, max_lines: usize) -> String {
225                    let lines: Vec<&str> = s.lines().collect();
226                    let char_count = s.len();
227
228                    // Check both line and character limits
229                    if lines.len() > max_lines || char_count > max_chars {
230                        // Take first and last portions of output
231                        let head_lines = max_lines / 2;
232                        let tail_lines = max_lines - head_lines;
233
234                        let mut result = String::new();
235
236                        // Add head lines
237                        for line in lines.iter().take(head_lines) {
238                            result.push_str(line);
239                            result.push('\n');
240                        }
241
242                        // Add truncation marker
243                        let omitted_lines = lines.len().saturating_sub(max_lines);
244                        result.push_str(&format!(
245                            "\n[... {omitted_lines} lines omitted ({char_count} total chars) ...]\n\n"
246                        ));
247
248                        // Add tail lines
249                        if tail_lines > 0 && lines.len() > head_lines {
250                            for line in lines.iter().skip(lines.len().saturating_sub(tail_lines)) {
251                                result.push_str(line);
252                                result.push('\n');
253                            }
254                        }
255
256                        result
257                    } else {
258                        s.to_string()
259                    }
260                }
261
262                const MAX_STDOUT_CHARS: usize = 128 * 1024; // 128KB
263                const MAX_STDOUT_LINES: usize = 2000;
264                const MAX_STDERR_CHARS: usize = 64 * 1024; // 64KB  
265                const MAX_STDERR_LINES: usize = 500;
266
267                let stdout_truncated =
268                    truncate_output(&r.stdout, MAX_STDOUT_CHARS, MAX_STDOUT_LINES);
269                let stderr_truncated =
270                    truncate_output(&r.stderr, MAX_STDERR_CHARS, MAX_STDERR_LINES);
271
272                let mut output = stdout_truncated;
273
274                if r.exit_code != 0 {
275                    if !output.is_empty() && !output.ends_with('\n') {
276                        output.push('\n');
277                    }
278                    output.push_str(&format!("Exit code: {}", r.exit_code));
279
280                    if !stderr_truncated.is_empty() {
281                        output.push_str(&format!("\nError output:\n{stderr_truncated}"));
282                    }
283                } else if !stderr_truncated.is_empty() {
284                    if !output.is_empty() && !output.ends_with('\n') {
285                        output.push('\n');
286                    }
287                    output.push_str(&format!("Error output:\n{stderr_truncated}"));
288                }
289
290                output
291            }
292            ToolResult::Glob(r) => {
293                if r.matches.is_empty() {
294                    format!("No files matching pattern: {}", r.pattern)
295                } else {
296                    r.matches.join("\n")
297                }
298            }
299            ToolResult::TodoRead(r) => {
300                if r.todos.is_empty() {
301                    "No todos found.".to_string()
302                } else {
303                    format!(
304                        "Remember to continue to update and read from the todo list as you make progress. Here is the current list:\n{}",
305                        serde_json::to_string_pretty(&r.todos)
306                            .unwrap_or_else(|_| "Failed to format todos".to_string())
307                    )
308                }
309            }
310            ToolResult::TodoWrite(r) => {
311                format!(
312                    "Todos have been {:?} successfully. Ensure that you continue to read and update the todo list as you work on tasks.\n{}",
313                    r.operation,
314                    serde_json::to_string_pretty(&r.todos)
315                        .unwrap_or_else(|_| "Failed to format todos".to_string())
316                )
317            }
318            ToolResult::Fetch(r) => {
319                format!("Fetched content from {}:\n{}", r.url, r.content)
320            }
321            ToolResult::Agent(r) => r.content.clone(),
322            ToolResult::External(r) => r.payload.clone(),
323            ToolResult::Error(e) => format!("Error: {e}"),
324        }
325    }
326
327    /// Get the variant name as a string for metadata
328    pub fn variant_name(&self) -> &'static str {
329        match self {
330            ToolResult::Search(_) => "Search",
331            ToolResult::FileList(_) => "FileList",
332            ToolResult::FileContent(_) => "FileContent",
333            ToolResult::Edit(_) => "Edit",
334            ToolResult::Bash(_) => "Bash",
335            ToolResult::Glob(_) => "Glob",
336            ToolResult::TodoRead(_) => "TodoRead",
337            ToolResult::TodoWrite(_) => "TodoWrite",
338            ToolResult::Fetch(_) => "Fetch",
339            ToolResult::Agent(_) => "Agent",
340            ToolResult::External(_) => "External",
341            ToolResult::Error(_) => "Error",
342        }
343    }
344}