Skip to main content

vtcode_core/tools/
unified_error.rs

1//! Unified tool error envelope for consistent error handling across execution paths
2//!
3//! This module consolidates error types from:
4//! - `handlers::ToolCallError`
5//! - `handlers::ToolError`
6//! - `middleware::MiddlewareError`
7//! - `improvements_errors::ImprovementError`
8//! - Registry execution errors
9//!
10//! By routing all errors through this envelope, we achieve:
11//! - Consistent retry classification
12//! - Uniform user-facing messaging
13//! - Preserved debug context for diagnostics
14
15use std::fmt;
16use thiserror::Error;
17use vtcode_commons::ErrorCategory;
18
19/// Unified error severity levels
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum ErrorSeverity {
22    /// Transient error, safe to retry
23    Transient,
24    /// Permanent error, do not retry
25    Permanent,
26    /// User intervention required (HITL)
27    RequiresApproval,
28    /// Tool blocked by policy
29    PolicyBlocked,
30}
31
32/// Unified error kind for classification
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum UnifiedErrorKind {
35    /// Network or I/O timeout
36    Timeout,
37    /// Network connectivity issue
38    Network,
39    /// Rate limit exceeded
40    RateLimit,
41    /// Invalid arguments from LLM
42    ArgumentValidation,
43    /// Tool not found or unavailable
44    ToolNotFound,
45    /// Permission denied by policy
46    PermissionDenied,
47    /// Sandbox execution failed or denied
48    SandboxFailure,
49    /// Internal tool error
50    InternalError,
51    /// Circuit breaker open
52    CircuitOpen,
53    /// Resource exhausted (memory, disk, etc.)
54    ResourceExhausted,
55    /// User cancelled operation
56    Cancelled,
57    /// Policy violation (blocked by safety gateway)
58    PolicyViolation,
59    /// Plan mode violation (mutating tool in read-only mode)
60    PlanModeViolation,
61    /// Execution failed (general tool execution failure)
62    ExecutionFailed,
63    /// Unknown/unclassified error
64    Unknown,
65}
66
67impl UnifiedErrorKind {
68    /// Whether this error kind is retryable
69    #[inline]
70    pub const fn is_retryable(&self) -> bool {
71        matches!(
72            self,
73            UnifiedErrorKind::Timeout
74                | UnifiedErrorKind::Network
75                | UnifiedErrorKind::RateLimit
76                | UnifiedErrorKind::CircuitOpen
77        )
78    }
79
80    /// Whether this is an LLM mistake (argument error) vs tool failure
81    #[inline]
82    pub const fn is_llm_mistake(&self) -> bool {
83        matches!(self, UnifiedErrorKind::ArgumentValidation)
84    }
85}
86
87/// Unified tool error envelope
88#[derive(Error, Debug)]
89pub struct UnifiedToolError {
90    /// Error classification
91    pub kind: UnifiedErrorKind,
92    /// Severity level
93    pub severity: ErrorSeverity,
94    /// User-facing message (safe to display)
95    pub user_message: String,
96    /// Debug context (tool name, args, etc.)
97    pub debug_context: Option<DebugContext>,
98    /// Original error (for chaining)
99    #[source]
100    pub source: Option<anyhow::Error>,
101}
102
103impl fmt::Display for UnifiedToolError {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        write!(f, "{}", self.user_message)
106    }
107}
108
109/// Debug context for error diagnostics
110#[derive(Debug, Clone)]
111pub struct DebugContext {
112    /// Tool that produced the error
113    pub tool_name: String,
114    /// Invocation ID for correlation
115    pub invocation_id: Option<String>,
116    /// Attempt number (for retries)
117    pub attempt: u32,
118    /// Additional context key-value pairs
119    pub metadata: Vec<(String, String)>,
120}
121
122impl UnifiedToolError {
123    /// Ensure debug context exists and return a mutable reference.
124    fn debug_context_mut(&mut self) -> &mut DebugContext {
125        self.debug_context.get_or_insert_with(|| DebugContext {
126            tool_name: String::new(),
127            invocation_id: None,
128            attempt: 1,
129            metadata: Vec::new(),
130        })
131    }
132
133    /// Create a new unified error
134    #[must_use]
135    pub fn new(kind: UnifiedErrorKind, user_message: impl Into<String>) -> Self {
136        let severity = match kind {
137            UnifiedErrorKind::Timeout
138            | UnifiedErrorKind::Network
139            | UnifiedErrorKind::RateLimit
140            | UnifiedErrorKind::CircuitOpen => ErrorSeverity::Transient,
141            UnifiedErrorKind::PermissionDenied => ErrorSeverity::RequiresApproval,
142            _ => ErrorSeverity::Permanent,
143        };
144
145        Self {
146            kind,
147            severity,
148            user_message: user_message.into(),
149            debug_context: None,
150            source: None,
151        }
152    }
153
154    /// Add debug context
155    #[must_use]
156    pub fn with_context(mut self, ctx: DebugContext) -> Self {
157        self.debug_context = Some(ctx);
158        self
159    }
160
161    /// Add source error
162    #[must_use]
163    pub fn with_source(mut self, err: anyhow::Error) -> Self {
164        self.source = Some(err);
165        self
166    }
167
168    /// Add tool name to debug context
169    #[must_use]
170    pub fn with_tool_name(mut self, name: &str) -> Self {
171        self.debug_context_mut().tool_name = name.to_string();
172        self
173    }
174
175    /// Add invocation ID to debug context
176    #[must_use]
177    pub fn with_invocation_id(mut self, id: crate::tools::invocation::ToolInvocationId) -> Self {
178        self.debug_context_mut().invocation_id = Some(id.to_string());
179        self
180    }
181
182    /// Add duration metadata
183    #[must_use]
184    pub fn with_duration(mut self, duration: std::time::Duration) -> Self {
185        self.debug_context_mut()
186            .metadata
187            .push(("duration_ms".to_string(), duration.as_millis().to_string()));
188        self
189    }
190
191    /// Check if error is retryable
192    #[inline]
193    #[must_use]
194    pub fn is_retryable(&self) -> bool {
195        self.kind.is_retryable() && matches!(self.severity, ErrorSeverity::Transient)
196    }
197
198    /// Check if this is an LLM argument error (should not count toward circuit breaker)
199    #[inline]
200    #[must_use]
201    pub fn is_llm_mistake(&self) -> bool {
202        self.kind.is_llm_mistake()
203    }
204
205    /// Return the canonical VT Code error category for this tool error.
206    #[inline]
207    #[must_use]
208    pub fn category(&self) -> ErrorCategory {
209        ErrorCategory::from(self.kind)
210    }
211}
212
213/// Classify an `anyhow::Error` into a `UnifiedErrorKind`.
214///
215/// This is the crate-level error classifier. The registry-level equivalent is
216/// `registry::error::classify_error` which produces `ToolErrorType`. Both delegate
217/// to `vtcode_commons::classify_anyhow_error` and convert to their respective types.
218/// `UnifiedErrorKind` is used by the crate-level error envelope (`UnifiedToolError`);
219/// `ToolErrorType` is used by the registry execution facade for retry semantics.
220#[cold]
221pub fn classify_error(err: &anyhow::Error) -> UnifiedErrorKind {
222    let category = vtcode_commons::classify_anyhow_error(err);
223    UnifiedErrorKind::from(category)
224}
225
226// === Bridge conversions between ErrorCategory and UnifiedErrorKind ===
227
228impl From<ErrorCategory> for UnifiedErrorKind {
229    fn from(cat: ErrorCategory) -> Self {
230        match cat {
231            ErrorCategory::Network | ErrorCategory::ServiceUnavailable => UnifiedErrorKind::Network,
232            ErrorCategory::Timeout => UnifiedErrorKind::Timeout,
233            ErrorCategory::RateLimit => UnifiedErrorKind::RateLimit,
234            ErrorCategory::CircuitOpen => UnifiedErrorKind::CircuitOpen,
235            ErrorCategory::Authentication => UnifiedErrorKind::PermissionDenied,
236            ErrorCategory::InvalidParameters => UnifiedErrorKind::ArgumentValidation,
237            ErrorCategory::ToolNotFound => UnifiedErrorKind::ToolNotFound,
238            ErrorCategory::ResourceNotFound => UnifiedErrorKind::ToolNotFound,
239            ErrorCategory::PermissionDenied => UnifiedErrorKind::PermissionDenied,
240            ErrorCategory::PolicyViolation => UnifiedErrorKind::PolicyViolation,
241            ErrorCategory::PlanModeViolation => UnifiedErrorKind::PlanModeViolation,
242            ErrorCategory::SandboxFailure => UnifiedErrorKind::SandboxFailure,
243            ErrorCategory::ResourceExhausted => UnifiedErrorKind::ResourceExhausted,
244            ErrorCategory::Cancelled => UnifiedErrorKind::Cancelled,
245            ErrorCategory::ExecutionError => UnifiedErrorKind::ExecutionFailed,
246        }
247    }
248}
249
250impl From<UnifiedErrorKind> for ErrorCategory {
251    fn from(kind: UnifiedErrorKind) -> Self {
252        match kind {
253            UnifiedErrorKind::Timeout => ErrorCategory::Timeout,
254            UnifiedErrorKind::Network => ErrorCategory::Network,
255            UnifiedErrorKind::RateLimit => ErrorCategory::RateLimit,
256            UnifiedErrorKind::ArgumentValidation => ErrorCategory::InvalidParameters,
257            UnifiedErrorKind::ToolNotFound => ErrorCategory::ToolNotFound,
258            UnifiedErrorKind::PermissionDenied => ErrorCategory::PermissionDenied,
259            UnifiedErrorKind::SandboxFailure => ErrorCategory::SandboxFailure,
260            UnifiedErrorKind::InternalError => ErrorCategory::ExecutionError,
261            UnifiedErrorKind::CircuitOpen => ErrorCategory::CircuitOpen,
262            UnifiedErrorKind::ResourceExhausted => ErrorCategory::ResourceExhausted,
263            UnifiedErrorKind::Cancelled => ErrorCategory::Cancelled,
264            UnifiedErrorKind::PolicyViolation => ErrorCategory::PolicyViolation,
265            UnifiedErrorKind::PlanModeViolation => ErrorCategory::PlanModeViolation,
266            UnifiedErrorKind::ExecutionFailed => ErrorCategory::ExecutionError,
267            UnifiedErrorKind::Unknown => ErrorCategory::ExecutionError,
268        }
269    }
270}
271
272/// Convert from handlers::ToolCallError
273impl From<crate::tools::handlers::ToolCallError> for UnifiedToolError {
274    fn from(err: crate::tools::handlers::ToolCallError) -> Self {
275        use crate::tools::handlers::ToolCallError;
276        match err {
277            ToolCallError::Rejected(msg) => {
278                UnifiedToolError::new(UnifiedErrorKind::PermissionDenied, msg)
279            }
280            ToolCallError::RespondToModel(msg) => {
281                UnifiedToolError::new(UnifiedErrorKind::InternalError, msg)
282            }
283            ToolCallError::Internal(e) => {
284                let kind = classify_error(&e);
285                UnifiedToolError::new(kind, e.to_string()).with_source(e)
286            }
287            ToolCallError::Timeout(ms) => {
288                UnifiedToolError::new(UnifiedErrorKind::Timeout, format!("Timeout after {}ms", ms))
289            }
290        }
291    }
292}
293
294/// Convert from handlers::sandboxing::ToolError
295impl From<crate::tools::handlers::sandboxing::ToolError> for UnifiedToolError {
296    fn from(err: crate::tools::handlers::sandboxing::ToolError) -> Self {
297        use crate::tools::handlers::sandboxing::ToolError;
298        match err {
299            ToolError::Rejected(msg) => {
300                UnifiedToolError::new(UnifiedErrorKind::PermissionDenied, msg)
301            }
302            ToolError::Codex(e) => {
303                let kind = classify_error(&e);
304                UnifiedToolError::new(kind, e.to_string()).with_source(e)
305            }
306            ToolError::SandboxDenied(msg) => {
307                UnifiedToolError::new(UnifiedErrorKind::SandboxFailure, msg)
308            }
309            ToolError::Timeout(ms) => {
310                UnifiedToolError::new(UnifiedErrorKind::Timeout, format!("Timeout after {}ms", ms))
311            }
312        }
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn test_error_classification() {
322        assert_eq!(
323            classify_error(&anyhow::anyhow!("Connection timeout")),
324            UnifiedErrorKind::Timeout
325        );
326        assert_eq!(
327            classify_error(&anyhow::anyhow!("Rate limit exceeded")),
328            UnifiedErrorKind::RateLimit
329        );
330        assert_eq!(
331            classify_error(&anyhow::anyhow!("Permission denied")),
332            UnifiedErrorKind::PermissionDenied
333        );
334        assert_eq!(
335            classify_error(&anyhow::anyhow!("Invalid argument: missing path")),
336            UnifiedErrorKind::ArgumentValidation
337        );
338    }
339
340    #[test]
341    fn test_retryable_errors() {
342        let timeout_err = UnifiedToolError::new(UnifiedErrorKind::Timeout, "timeout");
343        assert!(timeout_err.is_retryable());
344
345        let perm_err = UnifiedToolError::new(UnifiedErrorKind::PermissionDenied, "denied");
346        assert!(!perm_err.is_retryable());
347    }
348
349    #[test]
350    fn test_llm_mistake_classification() {
351        let arg_err = UnifiedToolError::new(UnifiedErrorKind::ArgumentValidation, "bad args");
352        assert!(arg_err.is_llm_mistake());
353
354        let net_err = UnifiedToolError::new(UnifiedErrorKind::Network, "network down");
355        assert!(!net_err.is_llm_mistake());
356    }
357}