Skip to main content

zeph_tools/
error_taxonomy.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! 12-category tool invocation error taxonomy (arXiv:2601.16280).
5//!
6//! Provides fine-grained error classification beyond the binary `ErrorKind`
7//! (Transient/Permanent), enabling category-specific recovery strategies,
8//! structured LLM feedback, and quality-attributable reputation scoring.
9
10use crate::executor::ErrorKind;
11
12/// Invocation phase in which a tool failure occurred, per arXiv:2601.16280.
13///
14/// Maps Zeph's `ToolErrorCategory` variants to the 4-phase diagnostic framework:
15/// Setup → `ParamHandling` → Execution → `ResultInterpretation`.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum ToolInvocationPhase {
19    /// Tool lookup/registration phase: was the tool name valid?
20    Setup,
21    /// Parameter validation phase: were the provided arguments well-formed?
22    ParamHandling,
23    /// Runtime execution phase: did the tool run successfully?
24    Execution,
25    /// Output parsing/interpretation phase: was the result usable?
26    /// Reserved for future use — no current `ToolErrorCategory` maps here.
27    ResultInterpretation,
28}
29
30impl ToolInvocationPhase {
31    /// Human-readable label for audit logs.
32    #[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/// High-level error domain for recovery strategy dispatch.
44///
45/// Groups the 11 `ToolErrorCategory` variants into 4 domains that map to distinct
46/// recovery strategies in the agent loop. Does NOT replace `ToolErrorCategory` — it
47/// is a companion abstraction for coarse dispatch.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
49#[serde(rename_all = "snake_case")]
50pub enum ErrorDomain {
51    /// The agent selected the wrong tool or misunderstood the task.
52    /// Recovery: re-plan, pick a different tool or approach.
53    /// Categories: `ToolNotFound`
54    Planning,
55
56    /// The agent's output (parameters, types) was malformed.
57    /// Recovery: reformat parameters using tool schema, retry once.
58    /// Categories: `InvalidParameters`, `TypeMismatch`
59    Reflection,
60
61    /// External action failed due to policy or resource constraints.
62    /// Recovery: inform user, suggest alternative, or skip.
63    /// Categories: `PolicyBlocked`, `ConfirmationRequired`, `PermanentFailure`, `Cancelled`
64    Action,
65
66    /// Transient infrastructure failure.
67    /// Recovery: automatic retry with backoff.
68    /// Categories: `RateLimited`, `ServerError`, `NetworkError`, `Timeout`
69    System,
70}
71
72impl ErrorDomain {
73    /// Whether errors in this domain should trigger automatic retry.
74    #[must_use]
75    pub fn is_auto_retryable(self) -> bool {
76        matches!(self, Self::System)
77    }
78
79    /// Whether the LLM should be asked to fix its output.
80    #[must_use]
81    pub fn needs_llm_correction(self) -> bool {
82        matches!(self, Self::Reflection | Self::Planning)
83    }
84
85    /// Human-readable label for audit logs.
86    #[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/// Fine-grained 12-category classification of tool invocation errors.
98///
99/// Each category determines retry eligibility, LLM parameter reformat path,
100/// quality attribution for reputation scoring, and structured feedback content.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
102pub enum ToolErrorCategory {
103    // ── Initialization failures ──────────────────────────────────────────
104    /// Tool name not found in the registry (LLM requested a non-existent tool).
105    ToolNotFound,
106
107    // ── Parameter failures ───────────────────────────────────────────────
108    /// LLM provided invalid or missing parameters for the tool.
109    InvalidParameters,
110    /// Parameter type mismatch (e.g., string where integer expected).
111    TypeMismatch,
112
113    // ── Permission / policy failures ─────────────────────────────────────
114    /// Blocked by security policy (blocklist, sandbox, trust gate).
115    PolicyBlocked,
116    /// Requires user confirmation before execution.
117    ConfirmationRequired,
118
119    // ── Execution failures (permanent) ───────────────────────────────────
120    /// HTTP 403/404 or equivalent permanent resource rejection.
121    PermanentFailure,
122    /// Operation cancelled by the user.
123    Cancelled,
124
125    // ── Execution failures (transient) ───────────────────────────────────
126    /// HTTP 429 (rate limit) or resource exhaustion.
127    RateLimited,
128    /// HTTP 5xx or equivalent server-side error.
129    ServerError,
130    /// Network connectivity failure (DNS, connection refused, reset).
131    NetworkError,
132    /// Operation timed out.
133    Timeout,
134}
135
136impl ToolErrorCategory {
137    /// Whether this error category is eligible for automatic retry with backoff.
138    #[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    /// Whether the LLM should be asked to reformat parameters and retry.
147    ///
148    /// Only `InvalidParameters` and `TypeMismatch` trigger the reformat path.
149    /// A single reformat attempt is allowed; if it fails, the error is final.
150    #[must_use]
151    pub fn needs_parameter_reformat(self) -> bool {
152        matches!(self, Self::InvalidParameters | Self::TypeMismatch)
153    }
154
155    /// Whether this error is attributable to LLM output quality.
156    ///
157    /// Quality failures affect reputation scoring in triage routing and are the
158    /// only category for which `attempt_self_reflection` should be triggered.
159    /// Infrastructure errors (network, timeout, server, rate limit) are NOT
160    /// the model's fault and must never trigger self-reflection.
161    #[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    /// Map to the high-level error domain for recovery dispatch.
170    ///
171    /// Use the returned `ErrorDomain` to select a recovery strategy in the agent loop
172    /// instead of checking multiple predicate methods individually.
173    #[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    /// Coarse classification for backward compatibility with existing `ErrorKind`.
189    #[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    /// Map to the diagnostic invocation phase per arXiv:2601.16280.
199    #[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    /// Human-readable label for audit logs, TUI status indicators, and structured feedback.
216    #[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    /// Recovery suggestion for the LLM based on error category.
234    #[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/// Structured error feedback injected as `tool_result` content for classified errors.
259///
260/// Provides the LLM with actionable information about what went wrong and what to
261/// do next, replacing the opaque `[error] ...` string format.
262#[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    /// Format as a structured string for injection into `tool_result` content.
271    #[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/// Classify an HTTP status code into a `ToolErrorCategory`.
284#[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        // 404, 410, and all other non-success codes: permanent failure.
292        _ => ToolErrorCategory::PermanentFailure,
293    }
294}
295
296/// Classify an `io::Error` into a `ToolErrorCategory`.
297///
298/// # Note on `io::ErrorKind::NotFound`
299///
300/// `NotFound` from an `Execution` error means a file or binary was not found at the
301/// OS level (e.g., `bash: command not found`). This is NOT the same as "tool not found
302/// in registry" (`ToolNotFound`). We map it to `PermanentFailure` to avoid incorrectly
303/// penalizing the model for OS-level path issues.
304#[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        // WouldBlock / Interrupted are async runtime signals, not true network failures,
313        // but they are transient and retryable — map to ServerError as the generic
314        // retryable catch-all rather than NetworkError to avoid misleading audit labels.
315        std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted => {
316            ToolErrorCategory::ServerError
317        }
318        std::io::ErrorKind::PermissionDenied => ToolErrorCategory::PolicyBlocked,
319        // OS-level file/binary not found is a permanent execution failure, not a registry miss.
320        // ToolNotFound is reserved for registry misses (LLM requested an unknown tool name).
321        _ => 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        // Infrastructure errors must NOT be quality failures — they must not trigger
352        // self-reflection, as they are not attributable to LLM output quality.
353        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        // Retryable categories → Transient
373        assert_eq!(
374            ToolErrorCategory::NetworkError.error_kind(),
375            ErrorKind::Transient
376        );
377        assert_eq!(
378            ToolErrorCategory::Timeout.error_kind(),
379            ErrorKind::Transient
380        );
381        // Non-retryable → Permanent
382        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        // B2 fix: OS-level NotFound must NOT map to ToolNotFound.
415        // ToolNotFound is reserved for registry misses (LLM requested unknown tool name).
416        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    // ── classify_http_status: full coverage per taxonomy spec ────────────────
472
473    #[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    // ── ToolErrorFeedback: category-specific content ──────────────────────────
492
493    #[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        // Suggestion must not mention auto-retry for a permanent error.
505        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        // RateLimited suggestion must mention retry but not promise it is automatic.
520        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        // Suggestion text must not promise "automatically" — retry may or may not fire
528        // (executor may not be retryable, or retries may be exhausted).
529        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        // Transient categories must produce retryable: true feedback.
546        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        // Permanent categories must produce retryable: false feedback.
561        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    // ── B4 regression: infrastructure errors must NOT be quality failures ─────
576
577    #[test]
578    fn b4_infrastructure_errors_not_quality_failures() {
579        // These categories must never trigger self-reflection (B4 fix).
580        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            // And they must be retryable.
591            assert!(cat.is_retryable(), "{cat:?} must be retryable");
592        }
593    }
594
595    #[test]
596    fn b4_quality_failures_may_trigger_reflection() {
597        // These categories should trigger self-reflection.
598        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            // Quality failures are not retryable.
608            assert!(!cat.is_retryable(), "{cat:?} must not be retryable");
609        }
610    }
611
612    // ── ErrorDomain mapping: all 11 categories ───────────────────────────────
613
614    #[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    // ── B2 regression: io::NotFound must NOT produce ToolNotFound ────────────
688
689    #[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    // ── ToolErrorCategory::Cancelled: not retryable, not quality failure ──────
706
707    #[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    // ── ToolInvocationPhase ──────────────────────────────────────────────────
715
716    #[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}