syncable_cli/agent/tools/
error.rs1use serde::Serialize;
35use serde_json::json;
36use std::fmt;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
43#[serde(rename_all = "snake_case")]
44pub enum ErrorCategory {
45 FileNotFound,
47 PermissionDenied,
49 PathOutsideBoundary,
51 ValidationFailed,
53 SerializationError,
55 ExternalCommandFailed,
57 CommandRejected,
59 Timeout,
61 NetworkError,
63 ResourceUnavailable,
65 InternalError,
67 UserCancelled,
69}
70
71impl ErrorCategory {
72 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 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 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
128pub 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
179pub 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 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
219pub trait ToolErrorContext<T, E> {
224 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
239pub 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}