1use crate::executor::ErrorKind;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum ToolInvocationPhase {
19 Setup,
21 ParamHandling,
23 Execution,
25 ResultInterpretation,
28}
29
30impl ToolInvocationPhase {
31 #[must_use]
33 pub fn label(self) -> &'static str {
34 match self {
35 Self::Setup => "setup",
36 Self::ParamHandling => "param_handling",
37 Self::Execution => "execution",
38 Self::ResultInterpretation => "result_interpretation",
39 }
40 }
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
49#[serde(rename_all = "snake_case")]
50pub enum ErrorDomain {
51 Planning,
55
56 Reflection,
60
61 Action,
65
66 System,
70}
71
72impl ErrorDomain {
73 #[must_use]
75 pub fn is_auto_retryable(self) -> bool {
76 matches!(self, Self::System)
77 }
78
79 #[must_use]
81 pub fn needs_llm_correction(self) -> bool {
82 matches!(self, Self::Reflection | Self::Planning)
83 }
84
85 #[must_use]
87 pub fn label(self) -> &'static str {
88 match self {
89 Self::Planning => "planning",
90 Self::Reflection => "reflection",
91 Self::Action => "action",
92 Self::System => "system",
93 }
94 }
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
102pub enum ToolErrorCategory {
103 ToolNotFound,
106
107 InvalidParameters,
110 TypeMismatch,
112
113 PolicyBlocked,
116 ConfirmationRequired,
118
119 PermanentFailure,
122 Cancelled,
124
125 RateLimited,
128 ServerError,
130 NetworkError,
132 Timeout,
134}
135
136impl ToolErrorCategory {
137 #[must_use]
139 pub fn is_retryable(self) -> bool {
140 matches!(
141 self,
142 Self::RateLimited | Self::ServerError | Self::NetworkError | Self::Timeout
143 )
144 }
145
146 #[must_use]
151 pub fn needs_parameter_reformat(self) -> bool {
152 matches!(self, Self::InvalidParameters | Self::TypeMismatch)
153 }
154
155 #[must_use]
162 pub fn is_quality_failure(self) -> bool {
163 matches!(
164 self,
165 Self::InvalidParameters | Self::TypeMismatch | Self::ToolNotFound
166 )
167 }
168
169 #[must_use]
174 pub fn domain(self) -> ErrorDomain {
175 match self {
176 Self::ToolNotFound => ErrorDomain::Planning,
177 Self::InvalidParameters | Self::TypeMismatch => ErrorDomain::Reflection,
178 Self::PolicyBlocked
179 | Self::ConfirmationRequired
180 | Self::PermanentFailure
181 | Self::Cancelled => ErrorDomain::Action,
182 Self::RateLimited | Self::ServerError | Self::NetworkError | Self::Timeout => {
183 ErrorDomain::System
184 }
185 }
186 }
187
188 #[must_use]
190 pub fn error_kind(self) -> ErrorKind {
191 if self.is_retryable() {
192 ErrorKind::Transient
193 } else {
194 ErrorKind::Permanent
195 }
196 }
197
198 #[must_use]
200 pub fn phase(self) -> ToolInvocationPhase {
201 match self {
202 Self::ToolNotFound => ToolInvocationPhase::Setup,
203 Self::InvalidParameters | Self::TypeMismatch => ToolInvocationPhase::ParamHandling,
204 Self::PolicyBlocked
205 | Self::ConfirmationRequired
206 | Self::PermanentFailure
207 | Self::Cancelled
208 | Self::RateLimited
209 | Self::ServerError
210 | Self::NetworkError
211 | Self::Timeout => ToolInvocationPhase::Execution,
212 }
213 }
214
215 #[must_use]
217 pub fn label(self) -> &'static str {
218 match self {
219 Self::ToolNotFound => "tool_not_found",
220 Self::InvalidParameters => "invalid_parameters",
221 Self::TypeMismatch => "type_mismatch",
222 Self::PolicyBlocked => "policy_blocked",
223 Self::ConfirmationRequired => "confirmation_required",
224 Self::PermanentFailure => "permanent_failure",
225 Self::Cancelled => "cancelled",
226 Self::RateLimited => "rate_limited",
227 Self::ServerError => "server_error",
228 Self::NetworkError => "network_error",
229 Self::Timeout => "timeout",
230 }
231 }
232
233 #[must_use]
235 pub fn suggestion(self) -> &'static str {
236 match self {
237 Self::ToolNotFound => {
238 "Check the tool name. Use tool_definitions to see available tools."
239 }
240 Self::InvalidParameters => "Review the tool schema and provide correct parameters.",
241 Self::TypeMismatch => "Check parameter types against the tool schema.",
242 Self::PolicyBlocked => {
243 "This operation is blocked by security policy. Try an alternative approach."
244 }
245 Self::ConfirmationRequired => "This operation requires user confirmation.",
246 Self::PermanentFailure => {
247 "This resource is not available. Try an alternative approach."
248 }
249 Self::Cancelled => "Operation was cancelled by the user.",
250 Self::RateLimited => "Rate limit exceeded. The system will retry if possible.",
251 Self::ServerError => "Server error. The system will retry if possible.",
252 Self::NetworkError => "Network error. The system will retry if possible.",
253 Self::Timeout => "Operation timed out. The system will retry if possible.",
254 }
255 }
256}
257
258#[derive(Debug, Clone, serde::Serialize)]
263pub struct ToolErrorFeedback {
264 pub category: ToolErrorCategory,
265 pub message: String,
266 pub retryable: bool,
267}
268
269impl ToolErrorFeedback {
270 #[must_use]
272 pub fn format_for_llm(&self) -> String {
273 format!(
274 "[tool_error]\ncategory: {}\nerror: {}\nsuggestion: {}\nretryable: {}",
275 self.category.label(),
276 self.message,
277 self.category.suggestion(),
278 self.retryable,
279 )
280 }
281}
282
283#[must_use]
285pub fn classify_http_status(status: u16) -> ToolErrorCategory {
286 match status {
287 400 | 422 => ToolErrorCategory::InvalidParameters,
288 401 | 403 => ToolErrorCategory::PolicyBlocked,
289 429 => ToolErrorCategory::RateLimited,
290 500..=599 => ToolErrorCategory::ServerError,
291 _ => ToolErrorCategory::PermanentFailure,
293 }
294}
295
296#[must_use]
305pub fn classify_io_error(err: &std::io::Error) -> ToolErrorCategory {
306 match err.kind() {
307 std::io::ErrorKind::TimedOut => ToolErrorCategory::Timeout,
308 std::io::ErrorKind::ConnectionRefused
309 | std::io::ErrorKind::ConnectionReset
310 | std::io::ErrorKind::ConnectionAborted
311 | std::io::ErrorKind::BrokenPipe => ToolErrorCategory::NetworkError,
312 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted => {
316 ToolErrorCategory::ServerError
317 }
318 std::io::ErrorKind::PermissionDenied => ToolErrorCategory::PolicyBlocked,
319 _ => ToolErrorCategory::PermanentFailure,
322 }
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 #[test]
330 fn retryable_categories() {
331 assert!(ToolErrorCategory::RateLimited.is_retryable());
332 assert!(ToolErrorCategory::ServerError.is_retryable());
333 assert!(ToolErrorCategory::NetworkError.is_retryable());
334 assert!(ToolErrorCategory::Timeout.is_retryable());
335
336 assert!(!ToolErrorCategory::InvalidParameters.is_retryable());
337 assert!(!ToolErrorCategory::TypeMismatch.is_retryable());
338 assert!(!ToolErrorCategory::ToolNotFound.is_retryable());
339 assert!(!ToolErrorCategory::PolicyBlocked.is_retryable());
340 assert!(!ToolErrorCategory::PermanentFailure.is_retryable());
341 assert!(!ToolErrorCategory::Cancelled.is_retryable());
342 assert!(!ToolErrorCategory::ConfirmationRequired.is_retryable());
343 }
344
345 #[test]
346 fn quality_failure_categories() {
347 assert!(ToolErrorCategory::InvalidParameters.is_quality_failure());
348 assert!(ToolErrorCategory::TypeMismatch.is_quality_failure());
349 assert!(ToolErrorCategory::ToolNotFound.is_quality_failure());
350
351 assert!(!ToolErrorCategory::NetworkError.is_quality_failure());
354 assert!(!ToolErrorCategory::ServerError.is_quality_failure());
355 assert!(!ToolErrorCategory::RateLimited.is_quality_failure());
356 assert!(!ToolErrorCategory::Timeout.is_quality_failure());
357 assert!(!ToolErrorCategory::PolicyBlocked.is_quality_failure());
358 assert!(!ToolErrorCategory::PermanentFailure.is_quality_failure());
359 assert!(!ToolErrorCategory::Cancelled.is_quality_failure());
360 }
361
362 #[test]
363 fn needs_parameter_reformat() {
364 assert!(ToolErrorCategory::InvalidParameters.needs_parameter_reformat());
365 assert!(ToolErrorCategory::TypeMismatch.needs_parameter_reformat());
366 assert!(!ToolErrorCategory::NetworkError.needs_parameter_reformat());
367 assert!(!ToolErrorCategory::ToolNotFound.needs_parameter_reformat());
368 }
369
370 #[test]
371 fn error_kind_backward_compat() {
372 assert_eq!(
374 ToolErrorCategory::NetworkError.error_kind(),
375 ErrorKind::Transient
376 );
377 assert_eq!(
378 ToolErrorCategory::Timeout.error_kind(),
379 ErrorKind::Transient
380 );
381 assert_eq!(
383 ToolErrorCategory::InvalidParameters.error_kind(),
384 ErrorKind::Permanent
385 );
386 assert_eq!(
387 ToolErrorCategory::PolicyBlocked.error_kind(),
388 ErrorKind::Permanent
389 );
390 }
391
392 #[test]
393 fn classify_http_status_codes() {
394 assert_eq!(classify_http_status(403), ToolErrorCategory::PolicyBlocked);
395 assert_eq!(
396 classify_http_status(404),
397 ToolErrorCategory::PermanentFailure
398 );
399 assert_eq!(
400 classify_http_status(422),
401 ToolErrorCategory::InvalidParameters
402 );
403 assert_eq!(classify_http_status(429), ToolErrorCategory::RateLimited);
404 assert_eq!(classify_http_status(500), ToolErrorCategory::ServerError);
405 assert_eq!(classify_http_status(503), ToolErrorCategory::ServerError);
406 assert_eq!(
407 classify_http_status(200),
408 ToolErrorCategory::PermanentFailure
409 );
410 }
411
412 #[test]
413 fn classify_io_not_found_is_permanent_not_tool_not_found() {
414 let err = std::io::Error::new(std::io::ErrorKind::NotFound, "No such file or directory");
417 assert_eq!(classify_io_error(&err), ToolErrorCategory::PermanentFailure);
418 }
419
420 #[test]
421 fn classify_io_connection_errors() {
422 let refused =
423 std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "connection refused");
424 assert_eq!(classify_io_error(&refused), ToolErrorCategory::NetworkError);
425
426 let reset = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "reset");
427 assert_eq!(classify_io_error(&reset), ToolErrorCategory::NetworkError);
428
429 let timed_out = std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out");
430 assert_eq!(classify_io_error(&timed_out), ToolErrorCategory::Timeout);
431 }
432
433 #[test]
434 fn tool_error_feedback_format() {
435 let fb = ToolErrorFeedback {
436 category: ToolErrorCategory::InvalidParameters,
437 message: "missing required field: url".to_owned(),
438 retryable: false,
439 };
440 let s = fb.format_for_llm();
441 assert!(s.contains("[tool_error]"));
442 assert!(s.contains("invalid_parameters"));
443 assert!(s.contains("missing required field: url"));
444 assert!(s.contains("retryable: false"));
445 }
446
447 #[test]
448 fn all_categories_have_labels() {
449 let categories = [
450 ToolErrorCategory::ToolNotFound,
451 ToolErrorCategory::InvalidParameters,
452 ToolErrorCategory::TypeMismatch,
453 ToolErrorCategory::PolicyBlocked,
454 ToolErrorCategory::ConfirmationRequired,
455 ToolErrorCategory::PermanentFailure,
456 ToolErrorCategory::Cancelled,
457 ToolErrorCategory::RateLimited,
458 ToolErrorCategory::ServerError,
459 ToolErrorCategory::NetworkError,
460 ToolErrorCategory::Timeout,
461 ];
462 for cat in categories {
463 assert!(!cat.label().is_empty(), "category {cat:?} has empty label");
464 assert!(
465 !cat.suggestion().is_empty(),
466 "category {cat:?} has empty suggestion"
467 );
468 }
469 }
470
471 #[test]
474 fn classify_http_400_is_invalid_parameters() {
475 assert_eq!(
476 classify_http_status(400),
477 ToolErrorCategory::InvalidParameters
478 );
479 }
480
481 #[test]
482 fn classify_http_401_is_policy_blocked() {
483 assert_eq!(classify_http_status(401), ToolErrorCategory::PolicyBlocked);
484 }
485
486 #[test]
487 fn classify_http_502_is_server_error() {
488 assert_eq!(classify_http_status(502), ToolErrorCategory::ServerError);
489 }
490
491 #[test]
494 fn feedback_permanent_failure_not_retryable() {
495 let fb = ToolErrorFeedback {
496 category: ToolErrorCategory::PermanentFailure,
497 message: "resource does not exist".to_owned(),
498 retryable: false,
499 };
500 let s = fb.format_for_llm();
501 assert!(s.contains("permanent_failure"));
502 assert!(s.contains("resource does not exist"));
503 assert!(s.contains("retryable: false"));
504 let suggestion = ToolErrorCategory::PermanentFailure.suggestion();
506 assert!(!suggestion.contains("retry automatically"), "{suggestion}");
507 }
508
509 #[test]
510 fn feedback_rate_limited_is_retryable_and_mentions_retry() {
511 let fb = ToolErrorFeedback {
512 category: ToolErrorCategory::RateLimited,
513 message: "too many requests".to_owned(),
514 retryable: true,
515 };
516 let s = fb.format_for_llm();
517 assert!(s.contains("rate_limited"));
518 assert!(s.contains("retryable: true"));
519 let suggestion = ToolErrorCategory::RateLimited.suggestion();
521 assert!(suggestion.contains("retry"), "{suggestion}");
522 assert!(!suggestion.contains("automatically"), "{suggestion}");
523 }
524
525 #[test]
526 fn transient_suggestion_neutral_no_automatically() {
527 for cat in [
530 ToolErrorCategory::ServerError,
531 ToolErrorCategory::NetworkError,
532 ToolErrorCategory::RateLimited,
533 ToolErrorCategory::Timeout,
534 ] {
535 let s = cat.suggestion();
536 assert!(
537 !s.contains("automatically"),
538 "{cat:?} suggestion must not promise automatic retry: {s}"
539 );
540 }
541 }
542
543 #[test]
544 fn feedback_retryable_matches_category_is_retryable() {
545 for cat in [
547 ToolErrorCategory::ServerError,
548 ToolErrorCategory::NetworkError,
549 ToolErrorCategory::RateLimited,
550 ToolErrorCategory::Timeout,
551 ] {
552 let fb = ToolErrorFeedback {
553 category: cat,
554 message: "error".to_owned(),
555 retryable: cat.is_retryable(),
556 };
557 assert!(fb.retryable, "{cat:?} feedback must be retryable");
558 }
559
560 for cat in [
562 ToolErrorCategory::InvalidParameters,
563 ToolErrorCategory::PolicyBlocked,
564 ToolErrorCategory::PermanentFailure,
565 ] {
566 let fb = ToolErrorFeedback {
567 category: cat,
568 message: "error".to_owned(),
569 retryable: cat.is_retryable(),
570 };
571 assert!(!fb.retryable, "{cat:?} feedback must not be retryable");
572 }
573 }
574
575 #[test]
578 fn b4_infrastructure_errors_not_quality_failures() {
579 for cat in [
581 ToolErrorCategory::NetworkError,
582 ToolErrorCategory::ServerError,
583 ToolErrorCategory::RateLimited,
584 ToolErrorCategory::Timeout,
585 ] {
586 assert!(
587 !cat.is_quality_failure(),
588 "{cat:?} must not be a quality failure"
589 );
590 assert!(cat.is_retryable(), "{cat:?} must be retryable");
592 }
593 }
594
595 #[test]
596 fn b4_quality_failures_may_trigger_reflection() {
597 for cat in [
599 ToolErrorCategory::InvalidParameters,
600 ToolErrorCategory::TypeMismatch,
601 ToolErrorCategory::ToolNotFound,
602 ] {
603 assert!(
604 cat.is_quality_failure(),
605 "{cat:?} must be a quality failure"
606 );
607 assert!(!cat.is_retryable(), "{cat:?} must not be retryable");
609 }
610 }
611
612 #[test]
615 fn domain_planning() {
616 assert_eq!(
617 ToolErrorCategory::ToolNotFound.domain(),
618 ErrorDomain::Planning
619 );
620 }
621
622 #[test]
623 fn domain_reflection() {
624 assert_eq!(
625 ToolErrorCategory::InvalidParameters.domain(),
626 ErrorDomain::Reflection
627 );
628 assert_eq!(
629 ToolErrorCategory::TypeMismatch.domain(),
630 ErrorDomain::Reflection
631 );
632 }
633
634 #[test]
635 fn domain_action() {
636 for cat in [
637 ToolErrorCategory::PolicyBlocked,
638 ToolErrorCategory::ConfirmationRequired,
639 ToolErrorCategory::PermanentFailure,
640 ToolErrorCategory::Cancelled,
641 ] {
642 assert_eq!(
643 cat.domain(),
644 ErrorDomain::Action,
645 "{cat:?} must map to Action"
646 );
647 }
648 }
649
650 #[test]
651 fn domain_system() {
652 for cat in [
653 ToolErrorCategory::RateLimited,
654 ToolErrorCategory::ServerError,
655 ToolErrorCategory::NetworkError,
656 ToolErrorCategory::Timeout,
657 ] {
658 assert_eq!(
659 cat.domain(),
660 ErrorDomain::System,
661 "{cat:?} must map to System"
662 );
663 }
664 }
665
666 #[test]
667 fn error_domain_helper_methods() {
668 assert!(ErrorDomain::System.is_auto_retryable());
669 assert!(!ErrorDomain::Planning.is_auto_retryable());
670 assert!(!ErrorDomain::Reflection.is_auto_retryable());
671 assert!(!ErrorDomain::Action.is_auto_retryable());
672
673 assert!(ErrorDomain::Reflection.needs_llm_correction());
674 assert!(ErrorDomain::Planning.needs_llm_correction());
675 assert!(!ErrorDomain::System.needs_llm_correction());
676 assert!(!ErrorDomain::Action.needs_llm_correction());
677 }
678
679 #[test]
680 fn error_domain_labels() {
681 assert_eq!(ErrorDomain::Planning.label(), "planning");
682 assert_eq!(ErrorDomain::Reflection.label(), "reflection");
683 assert_eq!(ErrorDomain::Action.label(), "action");
684 assert_eq!(ErrorDomain::System.label(), "system");
685 }
686
687 #[test]
690 fn b2_io_not_found_maps_to_permanent_failure_not_tool_not_found() {
691 let err = std::io::Error::new(std::io::ErrorKind::NotFound, "bash: command not found");
692 let cat = classify_io_error(&err);
693 assert_ne!(
694 cat,
695 ToolErrorCategory::ToolNotFound,
696 "OS-level NotFound must NOT map to ToolNotFound"
697 );
698 assert_eq!(
699 cat,
700 ToolErrorCategory::PermanentFailure,
701 "OS-level NotFound must map to PermanentFailure"
702 );
703 }
704
705 #[test]
708 fn cancelled_is_not_retryable_and_not_quality_failure() {
709 assert!(!ToolErrorCategory::Cancelled.is_retryable());
710 assert!(!ToolErrorCategory::Cancelled.is_quality_failure());
711 assert!(!ToolErrorCategory::Cancelled.needs_parameter_reformat());
712 }
713
714 #[test]
717 fn phase_setup_for_tool_not_found() {
718 assert_eq!(
719 ToolErrorCategory::ToolNotFound.phase(),
720 ToolInvocationPhase::Setup
721 );
722 }
723
724 #[test]
725 fn phase_param_handling() {
726 assert_eq!(
727 ToolErrorCategory::InvalidParameters.phase(),
728 ToolInvocationPhase::ParamHandling
729 );
730 assert_eq!(
731 ToolErrorCategory::TypeMismatch.phase(),
732 ToolInvocationPhase::ParamHandling
733 );
734 }
735
736 #[test]
737 fn phase_execution_for_runtime_errors() {
738 for cat in [
739 ToolErrorCategory::PolicyBlocked,
740 ToolErrorCategory::ConfirmationRequired,
741 ToolErrorCategory::PermanentFailure,
742 ToolErrorCategory::Cancelled,
743 ToolErrorCategory::RateLimited,
744 ToolErrorCategory::ServerError,
745 ToolErrorCategory::NetworkError,
746 ToolErrorCategory::Timeout,
747 ] {
748 assert_eq!(cat.phase(), ToolInvocationPhase::Execution, "{cat:?}");
749 }
750 }
751
752 #[test]
753 fn phase_label_non_empty() {
754 for phase in [
755 ToolInvocationPhase::Setup,
756 ToolInvocationPhase::ParamHandling,
757 ToolInvocationPhase::Execution,
758 ToolInvocationPhase::ResultInterpretation,
759 ] {
760 assert!(!phase.label().is_empty(), "{phase:?}");
761 }
762 }
763}