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    /// Planning workflow violation (mutating tool without write permissions)
60    PlanningPolicyViolation,
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    ///
70    /// Delegates to [`ErrorCategory::is_retryable`] so the retry set has a
71    /// single source of truth in `vtcode-commons`.
72    #[inline]
73    pub fn is_retryable(&self) -> bool {
74        ErrorCategory::from(*self).is_retryable()
75    }
76
77    /// Whether this is an LLM mistake (argument error) vs tool failure
78    #[inline]
79    pub const fn is_llm_mistake(&self) -> bool {
80        matches!(self, UnifiedErrorKind::ArgumentValidation)
81    }
82}
83
84/// Unified tool error envelope
85#[derive(Error, Debug)]
86pub struct UnifiedToolError {
87    /// Error classification
88    pub kind: UnifiedErrorKind,
89    /// Severity level
90    pub severity: ErrorSeverity,
91    /// User-facing message (safe to display)
92    pub user_message: String,
93    /// Debug context (tool name, args, etc.)
94    pub debug_context: Option<DebugContext>,
95    /// Original error (for chaining)
96    #[source]
97    pub source: Option<anyhow::Error>,
98}
99
100impl fmt::Display for UnifiedToolError {
101    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102        write!(f, "{}", self.user_message)
103    }
104}
105
106/// Debug context for error diagnostics
107#[derive(Debug, Clone)]
108pub struct DebugContext {
109    /// Tool that produced the error
110    pub tool_name: String,
111    /// Invocation ID for correlation
112    pub invocation_id: Option<String>,
113    /// Attempt number (for retries)
114    pub attempt: u32,
115    /// Additional context key-value pairs
116    pub metadata: Vec<(String, String)>,
117}
118
119impl UnifiedToolError {
120    /// Ensure debug context exists and return a mutable reference.
121    fn debug_context_mut(&mut self) -> &mut DebugContext {
122        self.debug_context.get_or_insert_with(|| DebugContext {
123            tool_name: String::new(),
124            invocation_id: None,
125            attempt: 1,
126            metadata: Vec::new(),
127        })
128    }
129
130    /// Create a new unified error
131    #[must_use]
132    pub fn new(kind: UnifiedErrorKind, user_message: impl Into<String>) -> Self {
133        let severity = match kind {
134            UnifiedErrorKind::Timeout
135            | UnifiedErrorKind::Network
136            | UnifiedErrorKind::RateLimit
137            | UnifiedErrorKind::CircuitOpen => ErrorSeverity::Transient,
138            UnifiedErrorKind::PermissionDenied => ErrorSeverity::RequiresApproval,
139            _ => ErrorSeverity::Permanent,
140        };
141
142        Self {
143            kind,
144            severity,
145            user_message: user_message.into(),
146            debug_context: None,
147            source: None,
148        }
149    }
150
151    /// Add debug context
152    #[must_use]
153    pub fn with_context(mut self, ctx: DebugContext) -> Self {
154        self.debug_context = Some(ctx);
155        self
156    }
157
158    /// Add source error
159    #[must_use]
160    pub fn with_source(mut self, err: anyhow::Error) -> Self {
161        self.source = Some(err);
162        self
163    }
164
165    /// Add tool name to debug context
166    #[must_use]
167    pub fn with_tool_name(mut self, name: &str) -> Self {
168        self.debug_context_mut().tool_name = name.to_string();
169        self
170    }
171
172    /// Add invocation ID to debug context
173    #[must_use]
174    pub fn with_invocation_id(mut self, id: crate::tools::invocation::ToolInvocationId) -> Self {
175        self.debug_context_mut().invocation_id = Some(id.to_string());
176        self
177    }
178
179    /// Add duration metadata
180    #[must_use]
181    pub fn with_duration(mut self, duration: std::time::Duration) -> Self {
182        self.debug_context_mut()
183            .metadata
184            .push(("duration_ms".to_string(), duration.as_millis().to_string()));
185        self
186    }
187
188    /// Check if error is retryable
189    #[inline]
190    #[must_use]
191    pub fn is_retryable(&self) -> bool {
192        self.kind.is_retryable() && matches!(self.severity, ErrorSeverity::Transient)
193    }
194
195    /// Check if this is an LLM argument error (should not count toward circuit breaker)
196    #[inline]
197    #[must_use]
198    pub fn is_llm_mistake(&self) -> bool {
199        self.kind.is_llm_mistake()
200    }
201
202    /// Return the canonical VT Code error category for this tool error.
203    #[inline]
204    #[must_use]
205    pub fn category(&self) -> ErrorCategory {
206        ErrorCategory::from(self.kind)
207    }
208}
209
210// === Bridge conversions between ErrorCategory and UnifiedErrorKind ===
211//
212// Classification of raw errors happens once, in
213// `vtcode_commons::classify_anyhow_error`; these impls only translate the
214// canonical `ErrorCategory` into this crate's envelope view and back.
215
216/// Lossy: `ServiceUnavailable` collapses into `Network` and `ResourceNotFound`
217/// collapses into `ToolNotFound`, because `UnifiedErrorKind` has no dedicated
218/// variants for them. Round-tripping through `ErrorCategory` does not restore
219/// the original category for those inputs.
220impl From<ErrorCategory> for UnifiedErrorKind {
221    fn from(cat: ErrorCategory) -> Self {
222        match cat {
223            ErrorCategory::Network | ErrorCategory::ServiceUnavailable => UnifiedErrorKind::Network,
224            ErrorCategory::Timeout => UnifiedErrorKind::Timeout,
225            ErrorCategory::RateLimit => UnifiedErrorKind::RateLimit,
226            ErrorCategory::CircuitOpen => UnifiedErrorKind::CircuitOpen,
227            ErrorCategory::Authentication => UnifiedErrorKind::PermissionDenied,
228            ErrorCategory::InvalidParameters => UnifiedErrorKind::ArgumentValidation,
229            ErrorCategory::ToolNotFound => UnifiedErrorKind::ToolNotFound,
230            ErrorCategory::ResourceNotFound => UnifiedErrorKind::ToolNotFound,
231            ErrorCategory::PermissionDenied => UnifiedErrorKind::PermissionDenied,
232            ErrorCategory::PolicyViolation => UnifiedErrorKind::PolicyViolation,
233            ErrorCategory::PlanningPolicyViolation => UnifiedErrorKind::PlanningPolicyViolation,
234            ErrorCategory::SandboxFailure => UnifiedErrorKind::SandboxFailure,
235            ErrorCategory::ResourceExhausted => UnifiedErrorKind::ResourceExhausted,
236            ErrorCategory::Cancelled => UnifiedErrorKind::Cancelled,
237            ErrorCategory::ExecutionError => UnifiedErrorKind::ExecutionFailed,
238        }
239    }
240}
241
242/// Lossy: `InternalError`, `ExecutionFailed`, and `Unknown` all collapse into
243/// `ExecutionError`, so distinct kinds can map to the same category.
244impl From<UnifiedErrorKind> for ErrorCategory {
245    fn from(kind: UnifiedErrorKind) -> Self {
246        match kind {
247            UnifiedErrorKind::Timeout => ErrorCategory::Timeout,
248            UnifiedErrorKind::Network => ErrorCategory::Network,
249            UnifiedErrorKind::RateLimit => ErrorCategory::RateLimit,
250            UnifiedErrorKind::ArgumentValidation => ErrorCategory::InvalidParameters,
251            UnifiedErrorKind::ToolNotFound => ErrorCategory::ToolNotFound,
252            UnifiedErrorKind::PermissionDenied => ErrorCategory::PermissionDenied,
253            UnifiedErrorKind::SandboxFailure => ErrorCategory::SandboxFailure,
254            UnifiedErrorKind::InternalError => ErrorCategory::ExecutionError,
255            UnifiedErrorKind::CircuitOpen => ErrorCategory::CircuitOpen,
256            UnifiedErrorKind::ResourceExhausted => ErrorCategory::ResourceExhausted,
257            UnifiedErrorKind::Cancelled => ErrorCategory::Cancelled,
258            UnifiedErrorKind::PolicyViolation => ErrorCategory::PolicyViolation,
259            UnifiedErrorKind::PlanningPolicyViolation => ErrorCategory::PlanningPolicyViolation,
260            UnifiedErrorKind::ExecutionFailed => ErrorCategory::ExecutionError,
261            UnifiedErrorKind::Unknown => ErrorCategory::ExecutionError,
262        }
263    }
264}
265
266/// Convert from handlers::ToolCallError
267impl From<crate::tools::handlers::ToolCallError> for UnifiedToolError {
268    fn from(err: crate::tools::handlers::ToolCallError) -> Self {
269        use crate::tools::handlers::ToolCallError;
270        match err {
271            ToolCallError::Rejected(msg) => UnifiedToolError::new(UnifiedErrorKind::PermissionDenied, msg),
272            ToolCallError::RespondToModel(msg) => UnifiedToolError::new(UnifiedErrorKind::InternalError, msg),
273            ToolCallError::Internal(e) => {
274                let kind = UnifiedErrorKind::from(vtcode_commons::classify_anyhow_error(&e));
275                UnifiedToolError::new(kind, e.to_string()).with_source(e)
276            }
277            ToolCallError::Timeout(ms) => {
278                UnifiedToolError::new(UnifiedErrorKind::Timeout, format!("Timeout after {ms}ms"))
279            }
280        }
281    }
282}
283
284/// Convert from handlers::sandboxing::ToolError
285impl From<crate::tools::handlers::sandboxing::ToolError> for UnifiedToolError {
286    fn from(err: crate::tools::handlers::sandboxing::ToolError) -> Self {
287        use crate::tools::handlers::sandboxing::ToolError;
288        match err {
289            ToolError::Rejected(msg) => UnifiedToolError::new(UnifiedErrorKind::PermissionDenied, msg),
290            ToolError::Codex(e) => {
291                let kind = UnifiedErrorKind::from(vtcode_commons::classify_anyhow_error(&e));
292                UnifiedToolError::new(kind, e.to_string()).with_source(e)
293            }
294            ToolError::SandboxDenied(msg) => UnifiedToolError::new(UnifiedErrorKind::SandboxFailure, msg),
295            ToolError::Timeout(ms) => UnifiedToolError::new(UnifiedErrorKind::Timeout, format!("Timeout after {ms}ms")),
296        }
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    fn classify(err: &anyhow::Error) -> UnifiedErrorKind {
305        UnifiedErrorKind::from(vtcode_commons::classify_anyhow_error(err))
306    }
307
308    #[test]
309    fn test_error_classification() {
310        assert_eq!(classify(&anyhow::anyhow!("Connection timeout")), UnifiedErrorKind::Timeout);
311        assert_eq!(classify(&anyhow::anyhow!("Rate limit exceeded")), UnifiedErrorKind::RateLimit);
312        assert_eq!(classify(&anyhow::anyhow!("Permission denied")), UnifiedErrorKind::PermissionDenied);
313        assert_eq!(classify(&anyhow::anyhow!("Invalid argument: missing path")), UnifiedErrorKind::ArgumentValidation);
314    }
315
316    #[test]
317    fn test_retryable_parity_with_error_category() {
318        let kinds = [
319            UnifiedErrorKind::Timeout,
320            UnifiedErrorKind::Network,
321            UnifiedErrorKind::RateLimit,
322            UnifiedErrorKind::ArgumentValidation,
323            UnifiedErrorKind::ToolNotFound,
324            UnifiedErrorKind::PermissionDenied,
325            UnifiedErrorKind::SandboxFailure,
326            UnifiedErrorKind::InternalError,
327            UnifiedErrorKind::CircuitOpen,
328            UnifiedErrorKind::ResourceExhausted,
329            UnifiedErrorKind::Cancelled,
330            UnifiedErrorKind::PolicyViolation,
331            UnifiedErrorKind::PlanningPolicyViolation,
332            UnifiedErrorKind::ExecutionFailed,
333            UnifiedErrorKind::Unknown,
334        ];
335        for kind in kinds {
336            let expected = matches!(
337                kind,
338                UnifiedErrorKind::Timeout
339                    | UnifiedErrorKind::Network
340                    | UnifiedErrorKind::RateLimit
341                    | UnifiedErrorKind::CircuitOpen
342            );
343            assert_eq!(kind.is_retryable(), expected, "kind: {kind:?}");
344        }
345    }
346
347    #[test]
348    fn test_retryable_errors() {
349        let timeout_err = UnifiedToolError::new(UnifiedErrorKind::Timeout, "timeout");
350        assert!(timeout_err.is_retryable());
351
352        let perm_err = UnifiedToolError::new(UnifiedErrorKind::PermissionDenied, "denied");
353        assert!(!perm_err.is_retryable());
354    }
355
356    #[test]
357    fn test_llm_mistake_classification() {
358        let arg_err = UnifiedToolError::new(UnifiedErrorKind::ArgumentValidation, "bad args");
359        assert!(arg_err.is_llm_mistake());
360
361        let net_err = UnifiedToolError::new(UnifiedErrorKind::Network, "network down");
362        assert!(!net_err.is_llm_mistake());
363    }
364}