1use 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#[derive(Debug, Clone, PartialEq)]
14pub enum ToolErrorCategory {
15 Transient {
17 error: String,
19 retry_strategy: RetryStrategy,
21 },
22 InputValidation {
24 error: String,
26 suggestion: Option<String>,
28 },
29 ExternalService {
31 error: String,
33 service: String,
35 retry_after: Option<Duration>,
37 },
38 Permission {
40 error: String,
42 required_permission: String,
44 },
45 Logic {
47 error: String,
49 context: String,
51 },
52 Resource {
54 error: String,
56 resource_type: ResourceType,
58 },
59 Unknown {
61 error: String,
63 },
64}
65
66impl ToolErrorCategory {
67 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 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 pub fn is_retryable(&self) -> bool {
95 matches!(
96 self,
97 ToolErrorCategory::Transient { .. } | ToolErrorCategory::ExternalService { .. }
98 )
99 }
100
101 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 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#[derive(Debug, Clone, PartialEq)]
140pub enum ResourceType {
141 FileNotFound,
143 DirectoryNotFound,
145 DiskSpace,
147 Memory,
149 ProcessLimit,
151 Other(String),
153}
154
155#[derive(Debug, Clone, PartialEq)]
157pub enum RetryStrategy {
158 NoRetry,
160 Immediate {
162 max_attempts: u32,
164 },
165 FixedDelay {
167 delay: Duration,
169 max_attempts: u32,
171 },
172 ExponentialBackoff {
174 base: Duration,
176 max_attempts: u32,
178 },
179}
180
181impl RetryStrategy {
182 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 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
381pub 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#[derive(Debug, Clone)]
465pub struct ToolOutcome {
466 pub tool_name: String,
468 pub success: bool,
470 pub retries: u32,
472 pub error_category: Option<ToolErrorCategory>,
474 pub execution_time_ms: u64,
476}
477
478impl ToolOutcome {
479 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 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}