syncable_cli/agent/tools/
error.rs

1//! Common error utilities for agent tools
2//!
3//! This module provides shared error handling infrastructure without replacing
4//! individual tool error types. Each tool keeps its own error type (e.g., ReadFileError,
5//! ShellError) but uses these utilities for consistent formatting.
6//!
7//! ## Pattern
8//!
9//! Tools should:
10//! 1. Keep their own error type deriving `thiserror::Error`
11//! 2. Use `ToolErrorContext` trait to add context when propagating errors
12//! 3. Use `format_error_for_llm` when returning error JSON to the agent
13//!
14//! ## Example
15//!
16//! ```ignore
17//! use crate::agent::tools::error::{ToolErrorContext, ErrorCategory, format_error_for_llm};
18//!
19//! fn read_config(&self, path: &Path) -> Result<String, ReadFileError> {
20//!     fs::read_to_string(path)
21//!         .with_tool_context("read_file", "reading configuration file")
22//!         .map_err(|e| ReadFileError(e))
23//! }
24//!
25//! // In tool call, for JSON error responses:
26//! let error_json = format_error_for_llm(
27//!     "read_file",
28//!     ErrorCategory::FileNotFound,
29//!     "File not found: config.yaml",
30//!     Some(vec!["Check if the file exists", "Verify the path is correct"]),
31//! );
32//! ```
33
34use serde::Serialize;
35use serde_json::json;
36use std::fmt;
37
38/// Common error categories for tool errors
39///
40/// These categories help the LLM understand what kind of error occurred
41/// and how to potentially recover from it.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
43#[serde(rename_all = "snake_case")]
44pub enum ErrorCategory {
45    /// File or path not found
46    FileNotFound,
47    /// Permission denied for operation
48    PermissionDenied,
49    /// Path is outside allowed directory
50    PathOutsideBoundary,
51    /// Input validation failed
52    ValidationFailed,
53    /// Serialization/deserialization error
54    SerializationError,
55    /// External command or tool failed
56    ExternalCommandFailed,
57    /// Command was rejected (not allowed)
58    CommandRejected,
59    /// Operation timed out
60    Timeout,
61    /// Network or connection error
62    NetworkError,
63    /// Resource not available
64    ResourceUnavailable,
65    /// Internal tool error
66    InternalError,
67    /// User cancelled the operation
68    UserCancelled,
69}
70
71impl ErrorCategory {
72    /// Returns a human-readable description of the category
73    pub fn description(&self) -> &'static str {
74        match self {
75            Self::FileNotFound => "The requested file or path was not found",
76            Self::PermissionDenied => "Permission was denied for this operation",
77            Self::PathOutsideBoundary => "The path is outside the allowed project directory",
78            Self::ValidationFailed => "Input validation failed",
79            Self::SerializationError => "Failed to serialize or deserialize data",
80            Self::ExternalCommandFailed => "An external command or tool failed",
81            Self::CommandRejected => "The command was rejected (not in allowed list)",
82            Self::Timeout => "The operation timed out",
83            Self::NetworkError => "A network or connection error occurred",
84            Self::ResourceUnavailable => "The requested resource is not available",
85            Self::InternalError => "An internal error occurred",
86            Self::UserCancelled => "The operation was cancelled by the user",
87        }
88    }
89
90    /// Returns whether this error is potentially recoverable
91    pub fn is_recoverable(&self) -> bool {
92        matches!(
93            self,
94            Self::FileNotFound
95                | Self::ValidationFailed
96                | Self::Timeout
97                | Self::NetworkError
98                | Self::ResourceUnavailable
99                | Self::UserCancelled
100        )
101    }
102
103    /// Returns the error code string for this category
104    pub fn code(&self) -> &'static str {
105        match self {
106            Self::FileNotFound => "FILE_NOT_FOUND",
107            Self::PermissionDenied => "PERMISSION_DENIED",
108            Self::PathOutsideBoundary => "PATH_OUTSIDE_BOUNDARY",
109            Self::ValidationFailed => "VALIDATION_FAILED",
110            Self::SerializationError => "SERIALIZATION_ERROR",
111            Self::ExternalCommandFailed => "EXTERNAL_COMMAND_FAILED",
112            Self::CommandRejected => "COMMAND_REJECTED",
113            Self::Timeout => "TIMEOUT",
114            Self::NetworkError => "NETWORK_ERROR",
115            Self::ResourceUnavailable => "RESOURCE_UNAVAILABLE",
116            Self::InternalError => "INTERNAL_ERROR",
117            Self::UserCancelled => "USER_CANCELLED",
118        }
119    }
120}
121
122impl fmt::Display for ErrorCategory {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        write!(f, "{}", self.code())
125    }
126}
127
128/// Format an error for LLM consumption
129///
130/// Returns a JSON string with structured error information that helps
131/// the LLM understand what went wrong and how to potentially fix it.
132///
133/// # Arguments
134///
135/// * `tool_name` - Name of the tool that produced the error
136/// * `category` - The error category
137/// * `message` - Human-readable error message
138/// * `suggestions` - Optional list of suggestions for recovery
139///
140/// # Example
141///
142/// ```ignore
143/// let error_json = format_error_for_llm(
144///     "read_file",
145///     ErrorCategory::FileNotFound,
146///     "File not found: /path/to/file.txt",
147///     Some(vec!["Check if the file exists", "Use list_directory to explore"]),
148/// );
149/// ```
150pub fn format_error_for_llm(
151    tool_name: &str,
152    category: ErrorCategory,
153    message: &str,
154    suggestions: Option<Vec<&str>>,
155) -> String {
156    let mut error_obj = json!({
157        "error": true,
158        "tool": tool_name,
159        "category": category,
160        "code": category.code(),
161        "message": message,
162        "recoverable": category.is_recoverable(),
163    });
164
165    if let Some(suggs) = suggestions {
166        if !suggs.is_empty() {
167            error_obj["suggestions"] = json!(suggs);
168        }
169    }
170
171    serde_json::to_string_pretty(&error_obj).unwrap_or_else(|_| {
172        format!(
173            r#"{{"error": true, "tool": "{}", "message": "{}"}}"#,
174            tool_name, message
175        )
176    })
177}
178
179/// Format an error with additional context fields
180///
181/// Similar to `format_error_for_llm` but allows adding arbitrary context.
182///
183/// # Arguments
184///
185/// * `tool_name` - Name of the tool that produced the error
186/// * `category` - The error category
187/// * `message` - Human-readable error message
188/// * `context` - Additional context as key-value pairs
189pub fn format_error_with_context(
190    tool_name: &str,
191    category: ErrorCategory,
192    message: &str,
193    context: &[(&str, serde_json::Value)],
194) -> String {
195    let mut error_obj = json!({
196        "error": true,
197        "tool": tool_name,
198        "category": category,
199        "code": category.code(),
200        "message": message,
201        "recoverable": category.is_recoverable(),
202    });
203
204    // Add context fields
205    if let Some(obj) = error_obj.as_object_mut() {
206        for (key, value) in context {
207            obj.insert((*key).to_string(), value.clone());
208        }
209    }
210
211    serde_json::to_string_pretty(&error_obj).unwrap_or_else(|_| {
212        format!(
213            r#"{{"error": true, "tool": "{}", "message": "{}"}}"#,
214            tool_name, message
215        )
216    })
217}
218
219/// Extension trait for adding tool context to errors
220///
221/// This trait provides a convenient way to add context when propagating errors
222/// through the ? operator.
223pub trait ToolErrorContext<T, E> {
224    /// Add tool context to an error
225    ///
226    /// # Arguments
227    ///
228    /// * `tool_name` - Name of the tool
229    /// * `operation` - Description of the operation being performed
230    fn with_tool_context(self, tool_name: &str, operation: &str) -> Result<T, String>;
231}
232
233impl<T, E: fmt::Display> ToolErrorContext<T, E> for Result<T, E> {
234    fn with_tool_context(self, tool_name: &str, operation: &str) -> Result<T, String> {
235        self.map_err(|e| format!("[{}] {} failed: {}", tool_name, operation, e))
236    }
237}
238
239/// Helper to detect error category from common error patterns
240///
241/// Analyzes an error message to suggest an appropriate category.
242/// This is a heuristic and may not always be accurate.
243pub fn detect_error_category(error_msg: &str) -> ErrorCategory {
244    let lower = error_msg.to_lowercase();
245
246    if lower.contains("not found")
247        || lower.contains("no such file")
248        || lower.contains("does not exist")
249    {
250        ErrorCategory::FileNotFound
251    } else if lower.contains("permission denied") || lower.contains("access denied") {
252        ErrorCategory::PermissionDenied
253    } else if lower.contains("outside") && (lower.contains("project") || lower.contains("boundary"))
254    {
255        ErrorCategory::PathOutsideBoundary
256    } else if lower.contains("timeout") || lower.contains("timed out") {
257        ErrorCategory::Timeout
258    } else if lower.contains("connection")
259        || lower.contains("network")
260        || lower.contains("unreachable")
261    {
262        ErrorCategory::NetworkError
263    } else if lower.contains("serialize")
264        || lower.contains("deserialize")
265        || lower.contains("json")
266        || lower.contains("parse")
267    {
268        ErrorCategory::SerializationError
269    } else if lower.contains("not allowed") || lower.contains("rejected") {
270        ErrorCategory::CommandRejected
271    } else if lower.contains("cancelled") || lower.contains("canceled") {
272        ErrorCategory::UserCancelled
273    } else if lower.contains("validation") || lower.contains("invalid") {
274        ErrorCategory::ValidationFailed
275    } else {
276        ErrorCategory::InternalError
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn test_error_category_codes() {
286        assert_eq!(ErrorCategory::FileNotFound.code(), "FILE_NOT_FOUND");
287        assert_eq!(ErrorCategory::PermissionDenied.code(), "PERMISSION_DENIED");
288        assert_eq!(ErrorCategory::CommandRejected.code(), "COMMAND_REJECTED");
289    }
290
291    #[test]
292    fn test_error_category_recoverable() {
293        assert!(ErrorCategory::FileNotFound.is_recoverable());
294        assert!(ErrorCategory::Timeout.is_recoverable());
295        assert!(!ErrorCategory::PermissionDenied.is_recoverable());
296        assert!(!ErrorCategory::InternalError.is_recoverable());
297    }
298
299    #[test]
300    fn test_format_error_for_llm() {
301        let json_str = format_error_for_llm(
302            "read_file",
303            ErrorCategory::FileNotFound,
304            "File not found: test.txt",
305            Some(vec!["Check path", "Use list_directory"]),
306        );
307
308        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
309        assert_eq!(parsed["error"], true);
310        assert_eq!(parsed["tool"], "read_file");
311        assert_eq!(parsed["code"], "FILE_NOT_FOUND");
312        assert_eq!(parsed["recoverable"], true);
313        assert!(parsed["suggestions"].is_array());
314    }
315
316    #[test]
317    fn test_format_error_with_context() {
318        let json_str = format_error_with_context(
319            "shell",
320            ErrorCategory::CommandRejected,
321            "Command not allowed",
322            &[
323                ("blocked_command", json!("rm -rf /")),
324                ("allowed_commands", json!(["ls", "cat"])),
325            ],
326        );
327
328        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
329        assert_eq!(parsed["error"], true);
330        assert_eq!(parsed["blocked_command"], "rm -rf /");
331        assert!(parsed["allowed_commands"].is_array());
332    }
333
334    #[test]
335    fn test_detect_error_category() {
336        assert_eq!(
337            detect_error_category("File not found: config.yaml"),
338            ErrorCategory::FileNotFound
339        );
340        assert_eq!(
341            detect_error_category("Permission denied"),
342            ErrorCategory::PermissionDenied
343        );
344        assert_eq!(
345            detect_error_category("Path is outside project boundary"),
346            ErrorCategory::PathOutsideBoundary
347        );
348        assert_eq!(
349            detect_error_category("Connection timeout"),
350            ErrorCategory::Timeout
351        );
352        assert_eq!(
353            detect_error_category("JSON parse error"),
354            ErrorCategory::SerializationError
355        );
356        assert_eq!(
357            detect_error_category("Command not allowed"),
358            ErrorCategory::CommandRejected
359        );
360    }
361
362    #[test]
363    fn test_tool_error_context() {
364        let result: Result<(), std::io::Error> = Err(std::io::Error::new(
365            std::io::ErrorKind::NotFound,
366            "file missing",
367        ));
368
369        let with_context = result.with_tool_context("read_file", "reading config");
370        assert!(with_context.is_err());
371
372        let err_msg = with_context.unwrap_err();
373        assert!(err_msg.contains("[read_file]"));
374        assert!(err_msg.contains("reading config failed"));
375    }
376}