Skip to main content

vtcode_core/tools/registry/
error.rs

1use anyhow::Error;
2use serde::{Deserialize, Serialize};
3use serde_json::{Value, json};
4use std::borrow::Cow;
5use vtcode_commons::ErrorCategory;
6
7use crate::retry::is_command_tool;
8use crate::retry::{RetryDecision, RetryPolicy};
9
10#[derive(Debug, Clone, Serialize, Deserialize, Default)]
11pub struct ToolErrorDebugContext {
12    pub surface: Option<String>,
13    pub attempt: Option<u32>,
14    pub invocation_id: Option<String>,
15    pub metadata: Vec<(String, String)>,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ToolExecutionError {
20    pub tool_name: String,
21    pub error_type: ToolErrorType,
22    pub category: ErrorCategory,
23    pub message: String,
24    pub retryable: bool,
25    pub is_recoverable: bool,
26    pub recovery_suggestions: Vec<Cow<'static, str>>,
27    pub retry_delay_ms: Option<u64>,
28    pub retry_after_ms: Option<u64>,
29    pub circuit_breaker_impact: bool,
30    pub partial_state_possible: bool,
31    pub rollback_performed: bool,
32    pub debug_context: Option<ToolErrorDebugContext>,
33    pub original_error: Option<String>,
34}
35
36#[derive(Debug, Clone, Copy, Serialize, Deserialize)] // Added Copy since it's a simple enum
37pub enum ToolErrorType {
38    InvalidParameters,
39    ToolNotFound,
40    PermissionDenied,
41    ResourceNotFound,
42    NetworkError,
43    Timeout,
44    ExecutionError,
45    PolicyViolation,
46}
47
48impl ToolErrorType {
49    /// Return the error type as a static string for serialization.
50    #[must_use]
51    pub const fn as_str(&self) -> &'static str {
52        match self {
53            Self::InvalidParameters => "InvalidParameters",
54            Self::ToolNotFound => "ToolNotFound",
55            Self::PermissionDenied => "PermissionDenied",
56            Self::ResourceNotFound => "ResourceNotFound",
57            Self::NetworkError => "NetworkError",
58            Self::Timeout => "Timeout",
59            Self::ExecutionError => "ExecutionError",
60            Self::PolicyViolation => "PolicyViolation",
61        }
62    }
63}
64
65impl ToolExecutionError {
66    #[inline]
67    #[must_use]
68    pub fn new(
69        tool_name: impl Into<String>,
70        error_type: ToolErrorType,
71        message: impl Into<String>,
72    ) -> Self {
73        let tool_name = tool_name.into();
74        let message = message.into();
75        let category = ErrorCategory::from(error_type);
76        let (retryable, is_recoverable, recovery_suggestions) =
77            generate_recovery_info(tool_name.as_str(), category, error_type);
78
79        // PTY/command tool timeouts should NOT be retried - the underlying process
80        // may still be running and retrying will cause Cargo.lock contention or
81        // other resource conflicts
82        Self {
83            tool_name,
84            error_type,
85            category,
86            message,
87            retryable,
88            is_recoverable,
89            recovery_suggestions,
90            retry_delay_ms: None,
91            retry_after_ms: None,
92            circuit_breaker_impact: category.should_trip_circuit_breaker(),
93            partial_state_possible: false,
94            rollback_performed: false,
95            debug_context: None,
96            original_error: None,
97        }
98    }
99
100    #[inline]
101    #[must_use]
102    pub fn with_original_error(
103        tool_name: impl Into<String>,
104        error_type: ToolErrorType,
105        message: impl Into<String>,
106        original_error: impl Into<String>,
107    ) -> Self {
108        let mut error = Self::new(tool_name, error_type, message);
109        error.original_error = Some(original_error.into());
110        error
111    }
112
113    #[must_use]
114    pub fn from_anyhow(
115        tool_name: impl Into<String>,
116        error: &Error,
117        attempt_index: u32,
118        partial_state_possible: bool,
119        rollback_performed: bool,
120        surface: Option<&str>,
121    ) -> Self {
122        let tool_name = tool_name.into();
123        let mut structured = Self::with_original_error(
124            tool_name.clone(),
125            classify_error(error),
126            error.to_string(),
127            format!("{error:#}"),
128        );
129        structured = RetryPolicy::default().apply_to_tool_execution_error(
130            structured,
131            attempt_index,
132            Some(tool_name.as_str()),
133        );
134        structured.partial_state_possible = partial_state_possible;
135        structured.rollback_performed = rollback_performed;
136        structured = apply_explicit_error_state(structured, tool_name.as_str(), error);
137        if let Some(surface) = surface {
138            structured = structured.with_surface(surface);
139        }
140        structured
141    }
142
143    #[must_use]
144    pub fn policy_violation(tool_name: impl Into<String>, message: impl Into<String>) -> Self {
145        Self::new(tool_name, ToolErrorType::PolicyViolation, message)
146    }
147
148    #[must_use]
149    pub fn with_retry_decision(mut self, decision: RetryDecision) -> Self {
150        self.category = decision.category;
151        self.retryable = decision.retryable;
152        self.retry_delay_ms = decision.delay.map(|delay| delay.as_millis() as u64);
153        self.retry_after_ms = decision.retry_after.map(|delay| delay.as_millis() as u64);
154        self.circuit_breaker_impact = decision.category.should_trip_circuit_breaker();
155        self
156    }
157
158    #[must_use]
159    pub fn with_partial_state(
160        mut self,
161        partial_state_possible: bool,
162        rollback_performed: bool,
163    ) -> Self {
164        self.partial_state_possible = partial_state_possible;
165        self.rollback_performed = rollback_performed;
166        self
167    }
168
169    #[must_use]
170    pub fn with_surface(mut self, surface: impl Into<String>) -> Self {
171        let debug = self
172            .debug_context
173            .get_or_insert_with(ToolErrorDebugContext::default);
174        debug.surface = Some(surface.into());
175        self
176    }
177
178    #[must_use]
179    pub fn with_attempt(mut self, attempt: u32) -> Self {
180        let debug = self
181            .debug_context
182            .get_or_insert_with(ToolErrorDebugContext::default);
183        debug.attempt = Some(attempt);
184        self
185    }
186
187    #[must_use]
188    pub fn with_invocation_id(mut self, invocation_id: impl Into<String>) -> Self {
189        let debug = self
190            .debug_context
191            .get_or_insert_with(ToolErrorDebugContext::default);
192        debug.invocation_id = Some(invocation_id.into());
193        self
194    }
195
196    #[must_use]
197    pub fn with_debug_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
198        let debug = self
199            .debug_context
200            .get_or_insert_with(ToolErrorDebugContext::default);
201        debug.metadata.push((key.into(), value.into()));
202        self
203    }
204
205    #[must_use]
206    pub fn with_tool_call_context(mut self, tool_name: &str, args: &Value) -> Self {
207        self.tool_name = tool_name.to_string();
208
209        if tool_name == crate::config::constants::tools::APPLY_PATCH {
210            return self;
211        }
212
213        let intent = crate::tools::tool_intent::classify_tool_intent(tool_name, args);
214        if intent.mutating || is_command_tool(tool_name) {
215            self.partial_state_possible = true;
216        }
217
218        self
219    }
220
221    #[must_use]
222    pub fn attempts_made(&self) -> Option<u32> {
223        self.debug_context
224            .as_ref()
225            .and_then(|context| context.attempt)
226    }
227
228    #[must_use]
229    pub fn retry_summary(&self) -> Option<String> {
230        let retry_count = self
231            .attempts_made()
232            .map(|attempts| attempts.saturating_sub(1))
233            .unwrap_or(0);
234
235        let mut summary = if matches!(self.category, ErrorCategory::CircuitOpen) {
236            Some("The service is pausing new calls after repeated transient failures.".to_string())
237        } else if retry_count > 0 {
238            let suffix = if retry_count == 1 { "" } else { "s" };
239            Some(format!(
240                "Retried {retry_count} time{suffix} before failing."
241            ))
242        } else {
243            None
244        };
245
246        if let Some(delay_ms) = self.retry_after_ms.or(self.retry_delay_ms) {
247            let delay = format_retry_delay(delay_ms);
248            match summary.as_mut() {
249                Some(existing) => {
250                    existing.push(' ');
251                    existing.push_str("Recommended wait: ");
252                    existing.push_str(&delay);
253                    existing.push('.');
254                }
255                None => {
256                    summary = Some(format!("Recommended wait: {delay}."));
257                }
258            }
259        }
260
261        summary
262    }
263
264    #[must_use]
265    pub fn user_message(&self) -> String {
266        let mut message = format!("[{}] {}", self.category.user_label(), self.message);
267
268        if self.rollback_performed {
269            message.push_str(" Any partial changes were rolled back.");
270        } else if self.partial_state_possible {
271            message.push_str(" Partial changes may still exist.");
272        }
273
274        if let Some(retry_summary) = self.retry_summary() {
275            message.push(' ');
276            message.push_str(&retry_summary);
277        }
278
279        if let Some(next_action) = self.recovery_suggestions.first() {
280            message.push_str(" Next: ");
281            message.push_str(next_action.as_ref());
282        }
283
284        message
285    }
286
287    #[must_use]
288    pub fn retry_delay(&self) -> Option<std::time::Duration> {
289        self.retry_delay_ms.map(std::time::Duration::from_millis)
290    }
291
292    #[must_use]
293    pub fn retry_after(&self) -> Option<std::time::Duration> {
294        self.retry_after_ms.map(std::time::Duration::from_millis)
295    }
296
297    #[must_use]
298    pub fn from_tool_output(output: &Value) -> Option<Self> {
299        let error_payload = output.get("error")?;
300        Self::from_error_payload(error_payload)
301    }
302
303    #[must_use]
304    pub fn from_error_payload(error_payload: &Value) -> Option<Self> {
305        if let Some(inner) = error_payload.get("error") {
306            return Self::from_error_payload(inner);
307        }
308
309        if error_payload.is_object() && error_payload.get("message").is_some() {
310            return serde_json::from_value(error_payload.clone())
311                .ok()
312                .or_else(|| {
313                    let tool_name = error_payload
314                        .get("tool_name")
315                        .and_then(Value::as_str)
316                        .unwrap_or("tool");
317                    let message = error_payload
318                        .get("message")
319                        .and_then(Value::as_str)
320                        .unwrap_or("Unknown tool execution error");
321                    let category = error_payload
322                        .get("category")
323                        .and_then(|value| serde_json::from_value(value.clone()).ok())
324                        .unwrap_or_else(|| vtcode_commons::classify_error_message(message));
325                    let error_type = error_payload
326                        .get("error_type")
327                        .and_then(Value::as_str)
328                        .map(parse_error_type)
329                        .unwrap_or_else(|| ToolErrorType::from(category));
330                    let mut structured =
331                        Self::new(tool_name.to_string(), error_type, message.to_string());
332                    structured.category = category;
333                    structured.retryable = error_payload
334                        .get("retryable")
335                        .and_then(Value::as_bool)
336                        .unwrap_or(structured.retryable);
337                    structured.is_recoverable = error_payload
338                        .get("is_recoverable")
339                        .and_then(Value::as_bool)
340                        .unwrap_or(structured.is_recoverable);
341                    structured.retry_delay_ms =
342                        error_payload.get("retry_delay_ms").and_then(Value::as_u64);
343                    structured.retry_after_ms =
344                        error_payload.get("retry_after_ms").and_then(Value::as_u64);
345                    structured.circuit_breaker_impact = error_payload
346                        .get("circuit_breaker_impact")
347                        .and_then(Value::as_bool)
348                        .unwrap_or(structured.circuit_breaker_impact);
349                    structured.partial_state_possible = error_payload
350                        .get("partial_state_possible")
351                        .and_then(Value::as_bool)
352                        .unwrap_or(false);
353                    structured.rollback_performed = error_payload
354                        .get("rollback_performed")
355                        .and_then(Value::as_bool)
356                        .unwrap_or(false);
357                    structured.original_error = error_payload
358                        .get("original_error")
359                        .and_then(Value::as_str)
360                        .map(ToOwned::to_owned);
361                    Some(structured)
362                });
363        }
364
365        error_payload.as_str().map(|message| {
366            let category = vtcode_commons::classify_error_message(message);
367            Self::new(
368                "tool".to_string(),
369                ToolErrorType::from(category),
370                message.to_string(),
371            )
372        })
373    }
374
375    #[must_use]
376    pub fn to_json_value(&self) -> Value {
377        json!({
378            "error": {
379                "tool_name": self.tool_name,
380                "error_type": self.error_type.as_str(),
381                "category": self.category,
382                "message": self.message,
383                "retryable": self.retryable,
384                "is_recoverable": self.is_recoverable,
385                "recovery_suggestions": self.recovery_suggestions,
386                "retry_delay_ms": self.retry_delay_ms,
387                "retry_after_ms": self.retry_after_ms,
388                "circuit_breaker_impact": self.circuit_breaker_impact,
389                "partial_state_possible": self.partial_state_possible,
390                "rollback_performed": self.rollback_performed,
391                "debug_context": self.debug_context,
392                "original_error": self.original_error,
393            }
394        })
395    }
396}
397
398impl std::fmt::Display for ToolExecutionError {
399    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
400        f.write_str(&self.message)
401    }
402}
403
404impl std::error::Error for ToolExecutionError {}
405
406/// Classify an `anyhow::Error` into a `ToolErrorType`.
407///
408/// This is the registry-level error classifier used by the execution facade for
409/// retry semantics. The crate-level equivalent is `unified_error::classify_error`
410/// which produces `UnifiedErrorKind`. Both delegate to the same underlying
411/// `vtcode_commons::classify_anyhow_error` and convert to their respective types.
412pub fn classify_error(error: &Error) -> ToolErrorType {
413    let category = vtcode_commons::classify_anyhow_error(error);
414    ToolErrorType::from(category)
415}
416
417// Use static string slices to avoid allocations for recovery suggestions.
418// Delegates to the shared `ErrorCategory` recovery suggestions where possible.
419#[inline]
420fn generate_recovery_info(
421    tool_name: &str,
422    category: ErrorCategory,
423    error_type: ToolErrorType,
424) -> (bool, bool, Vec<Cow<'static, str>>) {
425    let is_recoverable = category.is_retryable()
426        || matches!(
427            error_type,
428            ToolErrorType::InvalidParameters
429                | ToolErrorType::PermissionDenied
430                | ToolErrorType::ResourceNotFound
431        );
432    let retryable = if matches!(error_type, ToolErrorType::Timeout) && is_command_tool(tool_name) {
433        false
434    } else {
435        category.is_retryable()
436    };
437    (retryable, is_recoverable, category.recovery_suggestions())
438}
439
440fn parse_error_type(raw: &str) -> ToolErrorType {
441    match raw {
442        "InvalidParameters" => ToolErrorType::InvalidParameters,
443        "ToolNotFound" => ToolErrorType::ToolNotFound,
444        "PermissionDenied" => ToolErrorType::PermissionDenied,
445        "ResourceNotFound" => ToolErrorType::ResourceNotFound,
446        "NetworkError" => ToolErrorType::NetworkError,
447        "Timeout" => ToolErrorType::Timeout,
448        "ExecutionError" => ToolErrorType::ExecutionError,
449        "PolicyViolation" => ToolErrorType::PolicyViolation,
450        _ => ToolErrorType::ExecutionError,
451    }
452}
453
454fn format_retry_delay(delay_ms: u64) -> String {
455    if delay_ms >= 1_000 {
456        format!("{:.1}s", delay_ms as f64 / 1_000.0)
457    } else {
458        format!("{delay_ms}ms")
459    }
460}
461
462fn apply_explicit_error_state(
463    mut error: ToolExecutionError,
464    tool_name: &str,
465    source: &Error,
466) -> ToolExecutionError {
467    if tool_name != crate::config::constants::tools::APPLY_PATCH {
468        return error;
469    }
470
471    if let Some(patch_error) = source.downcast_ref::<crate::tools::editing::PatchError>() {
472        match patch_error {
473            crate::tools::editing::PatchError::RolledBack { .. } => {
474                error = error.with_partial_state(false, true);
475            }
476            crate::tools::editing::PatchError::Recovery { .. } => {
477                error = error.with_partial_state(true, false);
478            }
479            _ => {}
480        }
481    }
482
483    error
484}
485
486// === Bridge conversions between ErrorCategory and ToolErrorType ===
487
488impl From<ErrorCategory> for ToolErrorType {
489    fn from(cat: ErrorCategory) -> Self {
490        match cat {
491            ErrorCategory::InvalidParameters => ToolErrorType::InvalidParameters,
492            ErrorCategory::ToolNotFound => ToolErrorType::ToolNotFound,
493            ErrorCategory::ResourceNotFound => ToolErrorType::ResourceNotFound,
494            ErrorCategory::PermissionDenied => ToolErrorType::PermissionDenied,
495            ErrorCategory::Network | ErrorCategory::ServiceUnavailable => {
496                ToolErrorType::NetworkError
497            }
498            ErrorCategory::Timeout => ToolErrorType::Timeout,
499            ErrorCategory::PolicyViolation | ErrorCategory::PlanModeViolation => {
500                ToolErrorType::PolicyViolation
501            }
502            ErrorCategory::RateLimit => ToolErrorType::NetworkError,
503            ErrorCategory::CircuitOpen => ToolErrorType::ExecutionError,
504            ErrorCategory::Authentication => ToolErrorType::PermissionDenied,
505            ErrorCategory::SandboxFailure => ToolErrorType::PolicyViolation,
506            ErrorCategory::ResourceExhausted => ToolErrorType::ExecutionError,
507            ErrorCategory::Cancelled => ToolErrorType::ExecutionError,
508            ErrorCategory::ExecutionError => ToolErrorType::ExecutionError,
509        }
510    }
511}
512
513impl From<ToolErrorType> for ErrorCategory {
514    fn from(t: ToolErrorType) -> Self {
515        match t {
516            ToolErrorType::InvalidParameters => ErrorCategory::InvalidParameters,
517            ToolErrorType::ToolNotFound => ErrorCategory::ToolNotFound,
518            ToolErrorType::ResourceNotFound => ErrorCategory::ResourceNotFound,
519            ToolErrorType::PermissionDenied => ErrorCategory::PermissionDenied,
520            ToolErrorType::NetworkError => ErrorCategory::Network,
521            ToolErrorType::Timeout => ErrorCategory::Timeout,
522            ToolErrorType::PolicyViolation => ErrorCategory::PolicyViolation,
523            ToolErrorType::ExecutionError => ErrorCategory::ExecutionError,
524        }
525    }
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531    use anyhow::anyhow;
532
533    #[test]
534    fn classify_error_marks_rate_limit_as_network_error() {
535        let err = anyhow!("provider returned 429 Too Many Requests");
536        assert!(matches!(classify_error(&err), ToolErrorType::NetworkError));
537    }
538
539    #[test]
540    fn classify_error_marks_service_unavailable_as_network_error() {
541        let err = anyhow!("503 Service Unavailable");
542        assert!(matches!(classify_error(&err), ToolErrorType::NetworkError));
543    }
544
545    #[test]
546    fn classify_error_marks_weekly_usage_limit_as_execution_error() {
547        let err = anyhow!("you have reached your weekly usage limit");
548        assert!(matches!(
549            classify_error(&err),
550            ToolErrorType::ExecutionError
551        ));
552    }
553
554    #[test]
555    fn classify_error_marks_tool_not_found() {
556        let err = anyhow!("unknown tool: ask_questions");
557        assert!(matches!(classify_error(&err), ToolErrorType::ToolNotFound));
558    }
559
560    #[test]
561    fn classify_error_marks_policy_violation_before_permission() {
562        let err = anyhow!("tool permission denied by policy");
563        assert!(matches!(
564            classify_error(&err),
565            ToolErrorType::PolicyViolation
566        ));
567    }
568
569    #[test]
570    fn tool_call_context_marks_mutating_tools_as_partial_state_possible() {
571        let error = ToolExecutionError::new(
572            "write_file".to_string(),
573            ToolErrorType::ExecutionError,
574            "write failed".to_string(),
575        )
576        .with_tool_call_context(
577            crate::config::constants::tools::WRITE_FILE,
578            &serde_json::json!({"path": "note.txt", "content": "hello"}),
579        );
580
581        assert!(error.partial_state_possible);
582        assert!(!error.rollback_performed);
583    }
584
585    #[test]
586    fn tool_call_context_marks_apply_patch_failures_as_rolled_back() {
587        let source = Error::new(crate::tools::editing::PatchError::RolledBack {
588            original: Box::new(crate::tools::editing::PatchError::SegmentNotFound {
589                path: "src/lib.rs".to_string(),
590                snippet: "fn main()".to_string(),
591            }),
592        });
593        let error = ToolExecutionError::from_anyhow(
594            crate::config::constants::tools::APPLY_PATCH,
595            &source,
596            0,
597            false,
598            false,
599            None,
600        );
601
602        assert!(!error.partial_state_possible);
603        assert!(error.rollback_performed);
604    }
605
606    #[test]
607    fn user_message_includes_retry_summary_and_wait() {
608        let mut error = ToolExecutionError::new(
609            crate::config::constants::tools::READ_FILE.to_string(),
610            ToolErrorType::ExecutionError,
611            "read failed".to_string(),
612        )
613        .with_attempt(2);
614        error.retry_delay_ms = Some(1_500);
615
616        let message = error.user_message();
617
618        assert!(message.contains("Retried 1 time before failing."));
619        assert!(message.contains("Recommended wait: 1.5s."));
620    }
621}