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//! The [`ToolErrorCategory`] and [`ErrorDomain`] enums are defined in `zeph-common` and
7//! re-exported here for backwards compatibility. Tool-specific helpers (`classify_http_status`,
8//! `classify_io_error`, `ToolErrorFeedback`) remain in this module.
9
10pub use zeph_common::error_taxonomy::{ErrorDomain, ToolErrorCategory, ToolInvocationPhase};
11
12use crate::executor::ErrorKind;
13
14/// Extension trait adding `zeph-tools`-specific methods to [`ToolErrorCategory`].
15///
16/// This trait exists because `ToolErrorCategory` is defined in `zeph-common` but
17/// `ErrorKind` is defined in `zeph-tools`. Callers in `zeph-tools` use
18/// `use crate::error_taxonomy::ToolErrorCategoryExt` to access these methods.
19pub trait ToolErrorCategoryExt {
20    /// Coarse classification for backward compatibility with existing `ErrorKind`.
21    fn error_kind(self) -> ErrorKind;
22}
23
24impl ToolErrorCategoryExt for ToolErrorCategory {
25    fn error_kind(self) -> ErrorKind {
26        if self.is_retryable() {
27            ErrorKind::Transient
28        } else {
29            ErrorKind::Permanent
30        }
31    }
32}
33
34/// Structured error feedback injected as `tool_result` content for classified errors.
35///
36/// Provides the LLM with actionable information about what went wrong and what to
37/// do next, replacing the opaque `[error] ...` string format.
38#[derive(Debug, Clone, serde::Serialize)]
39pub struct ToolErrorFeedback {
40    /// Fine-grained category of the error (used to select suggestion and label).
41    pub category: ToolErrorCategory,
42    /// Human-readable error message from the failing executor.
43    pub message: String,
44    /// Whether the agent loop should attempt an automatic retry for this error.
45    pub retryable: bool,
46}
47
48impl ToolErrorFeedback {
49    /// Format as a structured string for injection into `tool_result` content.
50    #[must_use]
51    pub fn format_for_llm(&self) -> String {
52        format!(
53            "[tool_error]\ncategory: {}\nerror: {}\nsuggestion: {}\nretryable: {}",
54            self.category.label(),
55            self.message,
56            self.category.suggestion(),
57            self.retryable,
58        )
59    }
60}
61
62/// Classify an HTTP status code into a `ToolErrorCategory`.
63#[must_use]
64pub fn classify_http_status(status: u16) -> ToolErrorCategory {
65    match status {
66        400 | 422 => ToolErrorCategory::InvalidParameters,
67        401 | 403 => ToolErrorCategory::PolicyBlocked,
68        429 => ToolErrorCategory::RateLimited,
69        500..=599 => ToolErrorCategory::ServerError,
70        // 404, 410, and all other non-success codes: permanent failure.
71        _ => ToolErrorCategory::PermanentFailure,
72    }
73}
74
75/// Classify an `io::Error` into a `ToolErrorCategory`.
76///
77/// # Note on `io::ErrorKind::NotFound`
78///
79/// `NotFound` from an `Execution` error means a file or binary was not found at the
80/// OS level (e.g., `bash: command not found`). This is NOT the same as "tool not found
81/// in registry" (`ToolNotFound`). We map it to `PermanentFailure` to avoid incorrectly
82/// penalizing the model for OS-level path issues.
83#[must_use]
84pub fn classify_io_error(err: &std::io::Error) -> ToolErrorCategory {
85    match err.kind() {
86        std::io::ErrorKind::TimedOut => ToolErrorCategory::Timeout,
87        std::io::ErrorKind::ConnectionRefused
88        | std::io::ErrorKind::ConnectionReset
89        | std::io::ErrorKind::ConnectionAborted
90        | std::io::ErrorKind::BrokenPipe => ToolErrorCategory::NetworkError,
91        // WouldBlock / Interrupted are async runtime signals, not true network failures,
92        // but they are transient and retryable — map to ServerError as the generic
93        // retryable catch-all rather than NetworkError to avoid misleading audit labels.
94        std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted => {
95            ToolErrorCategory::ServerError
96        }
97        std::io::ErrorKind::PermissionDenied => ToolErrorCategory::PolicyBlocked,
98        // OS-level file/binary not found is a permanent execution failure, not a registry miss.
99        // ToolNotFound is reserved for registry misses (LLM requested an unknown tool name).
100        _ => ToolErrorCategory::PermanentFailure,
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::ToolErrorCategoryExt as _;
107    use super::*;
108
109    #[test]
110    fn retryable_categories() {
111        assert!(ToolErrorCategory::RateLimited.is_retryable());
112        assert!(ToolErrorCategory::ServerError.is_retryable());
113        assert!(ToolErrorCategory::NetworkError.is_retryable());
114        assert!(ToolErrorCategory::Timeout.is_retryable());
115
116        assert!(!ToolErrorCategory::InvalidParameters.is_retryable());
117        assert!(!ToolErrorCategory::TypeMismatch.is_retryable());
118        assert!(!ToolErrorCategory::ToolNotFound.is_retryable());
119        assert!(!ToolErrorCategory::PolicyBlocked.is_retryable());
120        assert!(!ToolErrorCategory::PermanentFailure.is_retryable());
121        assert!(!ToolErrorCategory::Cancelled.is_retryable());
122        assert!(!ToolErrorCategory::ConfirmationRequired.is_retryable());
123    }
124
125    #[test]
126    fn quality_failure_categories() {
127        assert!(ToolErrorCategory::InvalidParameters.is_quality_failure());
128        assert!(ToolErrorCategory::TypeMismatch.is_quality_failure());
129        assert!(ToolErrorCategory::ToolNotFound.is_quality_failure());
130
131        assert!(!ToolErrorCategory::NetworkError.is_quality_failure());
132        assert!(!ToolErrorCategory::ServerError.is_quality_failure());
133        assert!(!ToolErrorCategory::RateLimited.is_quality_failure());
134        assert!(!ToolErrorCategory::Timeout.is_quality_failure());
135        assert!(!ToolErrorCategory::PolicyBlocked.is_quality_failure());
136        assert!(!ToolErrorCategory::PermanentFailure.is_quality_failure());
137        assert!(!ToolErrorCategory::Cancelled.is_quality_failure());
138    }
139
140    #[test]
141    fn needs_parameter_reformat() {
142        assert!(ToolErrorCategory::InvalidParameters.needs_parameter_reformat());
143        assert!(ToolErrorCategory::TypeMismatch.needs_parameter_reformat());
144        assert!(!ToolErrorCategory::NetworkError.needs_parameter_reformat());
145        assert!(!ToolErrorCategory::ToolNotFound.needs_parameter_reformat());
146    }
147
148    #[test]
149    fn error_kind_backward_compat() {
150        assert_eq!(
151            ToolErrorCategory::NetworkError.error_kind(),
152            ErrorKind::Transient
153        );
154        assert_eq!(
155            ToolErrorCategory::Timeout.error_kind(),
156            ErrorKind::Transient
157        );
158        assert_eq!(
159            ToolErrorCategory::InvalidParameters.error_kind(),
160            ErrorKind::Permanent
161        );
162        assert_eq!(
163            ToolErrorCategory::PolicyBlocked.error_kind(),
164            ErrorKind::Permanent
165        );
166    }
167
168    #[test]
169    fn classify_http_status_codes() {
170        assert_eq!(classify_http_status(403), ToolErrorCategory::PolicyBlocked);
171        assert_eq!(
172            classify_http_status(404),
173            ToolErrorCategory::PermanentFailure
174        );
175        assert_eq!(
176            classify_http_status(422),
177            ToolErrorCategory::InvalidParameters
178        );
179        assert_eq!(classify_http_status(429), ToolErrorCategory::RateLimited);
180        assert_eq!(classify_http_status(500), ToolErrorCategory::ServerError);
181        assert_eq!(classify_http_status(503), ToolErrorCategory::ServerError);
182        assert_eq!(
183            classify_http_status(200),
184            ToolErrorCategory::PermanentFailure
185        );
186    }
187
188    #[test]
189    fn classify_io_not_found_is_permanent_not_tool_not_found() {
190        let err = std::io::Error::new(std::io::ErrorKind::NotFound, "No such file or directory");
191        assert_eq!(classify_io_error(&err), ToolErrorCategory::PermanentFailure);
192    }
193
194    #[test]
195    fn classify_io_connection_errors() {
196        let refused =
197            std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "connection refused");
198        assert_eq!(classify_io_error(&refused), ToolErrorCategory::NetworkError);
199
200        let reset = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "reset");
201        assert_eq!(classify_io_error(&reset), ToolErrorCategory::NetworkError);
202
203        let timed_out = std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out");
204        assert_eq!(classify_io_error(&timed_out), ToolErrorCategory::Timeout);
205    }
206
207    #[test]
208    fn tool_error_feedback_format() {
209        let fb = ToolErrorFeedback {
210            category: ToolErrorCategory::InvalidParameters,
211            message: "missing required field: url".to_owned(),
212            retryable: false,
213        };
214        let s = fb.format_for_llm();
215        assert!(s.contains("[tool_error]"));
216        assert!(s.contains("invalid_parameters"));
217        assert!(s.contains("missing required field: url"));
218        assert!(s.contains("retryable: false"));
219    }
220
221    #[test]
222    fn phase_setup_for_tool_not_found() {
223        assert_eq!(
224            ToolErrorCategory::ToolNotFound.phase(),
225            ToolInvocationPhase::Setup
226        );
227    }
228
229    #[test]
230    fn phase_param_handling() {
231        assert_eq!(
232            ToolErrorCategory::InvalidParameters.phase(),
233            ToolInvocationPhase::ParamHandling
234        );
235        assert_eq!(
236            ToolErrorCategory::TypeMismatch.phase(),
237            ToolInvocationPhase::ParamHandling
238        );
239    }
240}