Skip to main content

brainwires_tool_system/
error.rs

1//! Tool Error Taxonomy and Classification
2//!
3//! Based on AgentDebug paper (arxiv:2509.25370) - provides error classification
4//! for intelligent retry strategies and SEAL learning integration.
5
6use std::time::Duration;
7
8const DEFAULT_MAX_RETRY_ATTEMPTS: u32 = 3;
9const EXPONENTIAL_BACKOFF_BASE_SECS: u64 = 2;
10const DEFAULT_BACKOFF_BASE_MS: u64 = 500;
11
12/// Error taxonomy based on AgentDebug paper (arxiv:2509.25370)
13#[derive(Debug, Clone, PartialEq)]
14pub enum ToolErrorCategory {
15    /// Transient errors that may succeed on retry (network issues, timeouts).
16    Transient {
17        /// Error message.
18        error: String,
19        /// Retry strategy for this error.
20        retry_strategy: RetryStrategy,
21    },
22    /// Input validation errors - need different input parameters.
23    InputValidation {
24        /// Error message.
25        error: String,
26        /// Suggested fix for the input.
27        suggestion: Option<String>,
28    },
29    /// External service errors (API limits, service unavailable).
30    ExternalService {
31        /// Error message.
32        error: String,
33        /// Name of the external service.
34        service: String,
35        /// Suggested delay before retry.
36        retry_after: Option<Duration>,
37    },
38    /// Permission/access errors - won't succeed without user action.
39    Permission {
40        /// Error message.
41        error: String,
42        /// The permission required to proceed.
43        required_permission: String,
44    },
45    /// Logic errors - indicates model misunderstanding of tool usage.
46    Logic {
47        /// Error message.
48        error: String,
49        /// Context in which the logic error occurred.
50        context: String,
51    },
52    /// Resource errors - file not found, memory, disk space.
53    Resource {
54        /// Error message.
55        error: String,
56        /// Type of resource involved.
57        resource_type: ResourceType,
58    },
59    /// Unknown/unclassified errors.
60    Unknown {
61        /// Error message.
62        error: String,
63    },
64}
65
66impl ToolErrorCategory {
67    /// Return the category name as a static string.
68    pub fn category_name(&self) -> &'static str {
69        match self {
70            ToolErrorCategory::Transient { .. } => "transient",
71            ToolErrorCategory::InputValidation { .. } => "input_validation",
72            ToolErrorCategory::ExternalService { .. } => "external_service",
73            ToolErrorCategory::Permission { .. } => "permission",
74            ToolErrorCategory::Logic { .. } => "logic",
75            ToolErrorCategory::Resource { .. } => "resource",
76            ToolErrorCategory::Unknown { .. } => "unknown",
77        }
78    }
79
80    /// Return the error message string.
81    pub fn error_message(&self) -> &str {
82        match self {
83            ToolErrorCategory::Transient { error, .. } => error,
84            ToolErrorCategory::InputValidation { error, .. } => error,
85            ToolErrorCategory::ExternalService { error, .. } => error,
86            ToolErrorCategory::Permission { error, .. } => error,
87            ToolErrorCategory::Logic { error, .. } => error,
88            ToolErrorCategory::Resource { error, .. } => error,
89            ToolErrorCategory::Unknown { error } => error,
90        }
91    }
92
93    /// Whether this error category is retryable.
94    pub fn is_retryable(&self) -> bool {
95        matches!(
96            self,
97            ToolErrorCategory::Transient { .. } | ToolErrorCategory::ExternalService { .. }
98        )
99    }
100
101    /// Return the retry strategy for this error.
102    pub fn retry_strategy(&self) -> RetryStrategy {
103        match self {
104            ToolErrorCategory::Transient { retry_strategy, .. } => retry_strategy.clone(),
105            ToolErrorCategory::ExternalService { retry_after, .. } => {
106                if let Some(delay) = retry_after {
107                    RetryStrategy::FixedDelay {
108                        delay: *delay,
109                        max_attempts: DEFAULT_MAX_RETRY_ATTEMPTS,
110                    }
111                } else {
112                    RetryStrategy::ExponentialBackoff {
113                        base: Duration::from_secs(EXPONENTIAL_BACKOFF_BASE_SECS),
114                        max_attempts: DEFAULT_MAX_RETRY_ATTEMPTS,
115                    }
116                }
117            }
118            _ => RetryStrategy::NoRetry,
119        }
120    }
121
122    /// Get a suggestion for resolving this error, if available.
123    pub fn get_suggestion(&self) -> Option<String> {
124        match self {
125            ToolErrorCategory::InputValidation { suggestion, .. } => suggestion.clone(),
126            ToolErrorCategory::Permission {
127                required_permission,
128                ..
129            } => Some(format!("Requires {} permission", required_permission)),
130            ToolErrorCategory::Resource { resource_type, .. } => {
131                Some(format!("Resource issue: {:?}", resource_type))
132            }
133            _ => None,
134        }
135    }
136}
137
138/// Resource types for Resource errors
139#[derive(Debug, Clone, PartialEq)]
140pub enum ResourceType {
141    /// File not found.
142    FileNotFound,
143    /// Directory not found.
144    DirectoryNotFound,
145    /// Insufficient disk space.
146    DiskSpace,
147    /// Insufficient memory.
148    Memory,
149    /// Process limit reached.
150    ProcessLimit,
151    /// Other resource type.
152    Other(String),
153}
154
155/// Retry strategy for transient errors
156#[derive(Debug, Clone, PartialEq)]
157pub enum RetryStrategy {
158    /// Do not retry.
159    NoRetry,
160    /// Retry immediately up to a maximum number of attempts.
161    Immediate {
162        /// Maximum number of retry attempts.
163        max_attempts: u32,
164    },
165    /// Retry with a fixed delay between attempts.
166    FixedDelay {
167        /// Delay between retries.
168        delay: Duration,
169        /// Maximum number of retry attempts.
170        max_attempts: u32,
171    },
172    /// Retry with exponential backoff.
173    ExponentialBackoff {
174        /// Base duration for backoff calculation.
175        base: Duration,
176        /// Maximum number of retry attempts.
177        max_attempts: u32,
178    },
179}
180
181impl RetryStrategy {
182    /// Compute the delay for a given retry attempt, or `None` if exhausted.
183    pub fn delay_for_attempt(&self, attempt: u32) -> Option<Duration> {
184        match self {
185            RetryStrategy::NoRetry => None,
186            RetryStrategy::Immediate { max_attempts } => {
187                if attempt < *max_attempts {
188                    Some(Duration::ZERO)
189                } else {
190                    None
191                }
192            }
193            RetryStrategy::FixedDelay {
194                delay,
195                max_attempts,
196            } => {
197                if attempt < *max_attempts {
198                    Some(*delay)
199                } else {
200                    None
201                }
202            }
203            RetryStrategy::ExponentialBackoff { base, max_attempts } => {
204                if attempt < *max_attempts {
205                    Some(*base * 2u32.pow(attempt))
206                } else {
207                    None
208                }
209            }
210        }
211    }
212
213    /// Return the maximum number of retry attempts.
214    pub fn max_attempts(&self) -> u32 {
215        match self {
216            RetryStrategy::NoRetry => 0,
217            RetryStrategy::Immediate { max_attempts } => *max_attempts,
218            RetryStrategy::FixedDelay { max_attempts, .. } => *max_attempts,
219            RetryStrategy::ExponentialBackoff { max_attempts, .. } => *max_attempts,
220        }
221    }
222}
223
224impl Default for RetryStrategy {
225    fn default() -> Self {
226        RetryStrategy::ExponentialBackoff {
227            base: Duration::from_millis(DEFAULT_BACKOFF_BASE_MS),
228            max_attempts: DEFAULT_MAX_RETRY_ATTEMPTS,
229        }
230    }
231}
232
233struct ErrorPattern {
234    keywords: &'static [&'static str],
235    category_builder: fn(&str) -> ToolErrorCategory,
236}
237
238const ERROR_PATTERNS: &[ErrorPattern] = &[
239    ErrorPattern {
240        keywords: &[
241            "connection refused",
242            "connection reset",
243            "connection timed out",
244        ],
245        category_builder: |e| ToolErrorCategory::Transient {
246            error: e.to_string(),
247            retry_strategy: RetryStrategy::ExponentialBackoff {
248                base: Duration::from_secs(1),
249                max_attempts: 3,
250            },
251        },
252    },
253    ErrorPattern {
254        keywords: &["timeout", "timed out", "deadline exceeded"],
255        category_builder: |e| ToolErrorCategory::Transient {
256            error: e.to_string(),
257            retry_strategy: RetryStrategy::ExponentialBackoff {
258                base: Duration::from_secs(2),
259                max_attempts: 3,
260            },
261        },
262    },
263    ErrorPattern {
264        keywords: &["network", "dns", "host unreachable", "no route"],
265        category_builder: |e| ToolErrorCategory::Transient {
266            error: e.to_string(),
267            retry_strategy: RetryStrategy::ExponentialBackoff {
268                base: Duration::from_secs(1),
269                max_attempts: 3,
270            },
271        },
272    },
273    ErrorPattern {
274        keywords: &["rate limit", "too many requests", "429", "quota exceeded"],
275        category_builder: |e| ToolErrorCategory::ExternalService {
276            error: e.to_string(),
277            service: "API".to_string(),
278            retry_after: Some(Duration::from_secs(5)),
279        },
280    },
281    ErrorPattern {
282        keywords: &["service unavailable", "503", "502", "bad gateway"],
283        category_builder: |e| ToolErrorCategory::ExternalService {
284            error: e.to_string(),
285            service: "external".to_string(),
286            retry_after: Some(Duration::from_secs(3)),
287        },
288    },
289    ErrorPattern {
290        keywords: &["internal server error", "500"],
291        category_builder: |e| ToolErrorCategory::ExternalService {
292            error: e.to_string(),
293            service: "external".to_string(),
294            retry_after: Some(Duration::from_secs(2)),
295        },
296    },
297    ErrorPattern {
298        keywords: &["permission denied", "access denied", "forbidden", "403"],
299        category_builder: |e| ToolErrorCategory::Permission {
300            error: e.to_string(),
301            required_permission: "access".to_string(),
302        },
303    },
304    ErrorPattern {
305        keywords: &["unauthorized", "401", "authentication"],
306        category_builder: |e| ToolErrorCategory::Permission {
307            error: e.to_string(),
308            required_permission: "authentication".to_string(),
309        },
310    },
311    ErrorPattern {
312        keywords: &["read-only", "cannot write", "not writable"],
313        category_builder: |e| ToolErrorCategory::Permission {
314            error: e.to_string(),
315            required_permission: "write".to_string(),
316        },
317    },
318    ErrorPattern {
319        keywords: &[
320            "no such file",
321            "file not found",
322            "cannot find",
323            "does not exist",
324        ],
325        category_builder: |e| ToolErrorCategory::Resource {
326            error: e.to_string(),
327            resource_type: ResourceType::FileNotFound,
328        },
329    },
330    ErrorPattern {
331        keywords: &["not a directory", "is a directory", "directory not found"],
332        category_builder: |e| ToolErrorCategory::Resource {
333            error: e.to_string(),
334            resource_type: ResourceType::DirectoryNotFound,
335        },
336    },
337    ErrorPattern {
338        keywords: &["no space left", "disk full", "quota"],
339        category_builder: |e| ToolErrorCategory::Resource {
340            error: e.to_string(),
341            resource_type: ResourceType::DiskSpace,
342        },
343    },
344    ErrorPattern {
345        keywords: &["out of memory", "cannot allocate", "memory"],
346        category_builder: |e| ToolErrorCategory::Resource {
347            error: e.to_string(),
348            resource_type: ResourceType::Memory,
349        },
350    },
351    ErrorPattern {
352        keywords: &["invalid argument", "invalid parameter", "invalid input"],
353        category_builder: |e| ToolErrorCategory::InputValidation {
354            error: e.to_string(),
355            suggestion: Some("Check the input parameters".to_string()),
356        },
357    },
358    ErrorPattern {
359        keywords: &["missing required", "required field", "missing argument"],
360        category_builder: |e| ToolErrorCategory::InputValidation {
361            error: e.to_string(),
362            suggestion: Some("Provide all required parameters".to_string()),
363        },
364    },
365    ErrorPattern {
366        keywords: &["invalid path", "bad path", "malformed"],
367        category_builder: |e| ToolErrorCategory::InputValidation {
368            error: e.to_string(),
369            suggestion: Some("Check the path format".to_string()),
370        },
371    },
372    ErrorPattern {
373        keywords: &["type error", "expected", "invalid type"],
374        category_builder: |e| ToolErrorCategory::InputValidation {
375            error: e.to_string(),
376            suggestion: Some("Check parameter types".to_string()),
377        },
378    },
379];
380
381/// Classify an error from a tool result
382pub fn classify_error(tool_name: &str, error: &str) -> ToolErrorCategory {
383    let error_lower = error.to_lowercase();
384    for pattern in ERROR_PATTERNS {
385        if pattern.keywords.iter().any(|kw| error_lower.contains(kw)) {
386            return (pattern.category_builder)(error);
387        }
388    }
389    match tool_name {
390        "bash" | "Bash" | "execute_command" => classify_bash_error(error),
391        "read_file" | "ReadFile" | "Read" | "write_file" | "WriteFile" | "Write" => {
392            classify_file_error(error)
393        }
394        "web_search" | "WebSearch" | "web_fetch" | "WebFetch" | "fetch_url" => {
395            classify_web_error(error)
396        }
397        _ => ToolErrorCategory::Unknown {
398            error: error.to_string(),
399        },
400    }
401}
402
403fn classify_bash_error(error: &str) -> ToolErrorCategory {
404    let error_lower = error.to_lowercase();
405    if error_lower.contains("command not found") {
406        ToolErrorCategory::InputValidation {
407            error: error.to_string(),
408            suggestion: Some(
409                "Command does not exist. Check spelling or install the program.".to_string(),
410            ),
411        }
412    } else if error_lower.contains("exit code") || error_lower.contains("failed with") {
413        ToolErrorCategory::Logic {
414            error: error.to_string(),
415            context: "bash_execution".to_string(),
416        }
417    } else {
418        ToolErrorCategory::Unknown {
419            error: error.to_string(),
420        }
421    }
422}
423
424fn classify_file_error(error: &str) -> ToolErrorCategory {
425    let error_lower = error.to_lowercase();
426    if error_lower.contains("binary") || error_lower.contains("not valid utf-8") {
427        ToolErrorCategory::InputValidation {
428            error: error.to_string(),
429            suggestion: Some("File is binary or not valid text.".to_string()),
430        }
431    } else if error_lower.contains("too large") {
432        ToolErrorCategory::Resource {
433            error: error.to_string(),
434            resource_type: ResourceType::Memory,
435        }
436    } else {
437        ToolErrorCategory::Unknown {
438            error: error.to_string(),
439        }
440    }
441}
442
443fn classify_web_error(error: &str) -> ToolErrorCategory {
444    let error_lower = error.to_lowercase();
445    if error_lower.contains("ssl") || error_lower.contains("certificate") {
446        ToolErrorCategory::ExternalService {
447            error: error.to_string(),
448            service: "SSL/TLS".to_string(),
449            retry_after: None,
450        }
451    } else if error_lower.contains("redirect") {
452        ToolErrorCategory::InputValidation {
453            error: error.to_string(),
454            suggestion: Some("URL redirected. Follow the redirect or use the new URL.".to_string()),
455        }
456    } else {
457        ToolErrorCategory::Unknown {
458            error: error.to_string(),
459        }
460    }
461}
462
463/// Outcome of a tool execution (for SEAL learning)
464#[derive(Debug, Clone)]
465pub struct ToolOutcome {
466    /// Name of the tool that was executed.
467    pub tool_name: String,
468    /// Whether execution succeeded.
469    pub success: bool,
470    /// Number of retries performed.
471    pub retries: u32,
472    /// Error category if the tool failed.
473    pub error_category: Option<ToolErrorCategory>,
474    /// Execution time in milliseconds.
475    pub execution_time_ms: u64,
476}
477
478impl ToolOutcome {
479    /// Create a successful tool outcome.
480    pub fn success(tool_name: &str, retries: u32, execution_time_ms: u64) -> Self {
481        Self {
482            tool_name: tool_name.to_string(),
483            success: true,
484            retries,
485            error_category: None,
486            execution_time_ms,
487        }
488    }
489    /// Create a failed tool outcome.
490    pub fn failure(
491        tool_name: &str,
492        retries: u32,
493        error_category: ToolErrorCategory,
494        execution_time_ms: u64,
495    ) -> Self {
496        Self {
497            tool_name: tool_name.to_string(),
498            success: false,
499            retries,
500            error_category: Some(error_category),
501            execution_time_ms,
502        }
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509
510    #[test]
511    fn test_classify_transient_errors() {
512        let cat = classify_error("bash", "Connection refused");
513        assert!(matches!(cat, ToolErrorCategory::Transient { .. }));
514        assert!(cat.is_retryable());
515    }
516
517    #[test]
518    fn test_classify_permission_errors() {
519        let cat = classify_error("write_file", "Permission denied");
520        assert!(matches!(cat, ToolErrorCategory::Permission { .. }));
521        assert!(!cat.is_retryable());
522    }
523
524    #[test]
525    fn test_classify_resource_errors() {
526        let cat = classify_error("read_file", "No such file or directory");
527        assert!(matches!(
528            cat,
529            ToolErrorCategory::Resource {
530                resource_type: ResourceType::FileNotFound,
531                ..
532            }
533        ));
534    }
535
536    #[test]
537    fn test_retry_strategy_delay() {
538        let strategy = RetryStrategy::ExponentialBackoff {
539            base: Duration::from_millis(100),
540            max_attempts: 3,
541        };
542        assert_eq!(
543            strategy.delay_for_attempt(0),
544            Some(Duration::from_millis(100))
545        );
546        assert_eq!(
547            strategy.delay_for_attempt(1),
548            Some(Duration::from_millis(200))
549        );
550        assert_eq!(
551            strategy.delay_for_attempt(2),
552            Some(Duration::from_millis(400))
553        );
554        assert_eq!(strategy.delay_for_attempt(3), None);
555    }
556}