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::{RetryDecision, RetryPolicy, RetryPolicyCoreExt};
8use crate::tools::tool_intent::is_command_tool;
9
10const EXEC_SESSION_NOT_FOUND_CODE: &str = "exec_session_not_found";
11
12#[derive(Debug, Clone, Serialize, Deserialize, Default)]
13pub struct ToolErrorDebugContext {
14    pub surface: Option<String>,
15    pub attempt: Option<u32>,
16    pub invocation_id: Option<String>,
17    pub metadata: Vec<(String, String)>,
18}
19
20/// Typed patch diagnostics, independent of words quoted from source files.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub enum PatchFailure {
23    ContextMismatch { path: String, evidence: String },
24    Other,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum EditFailureKind {
30    NoMatch,
31    AmbiguousMatch,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct EditLocation {
36    pub start_line: usize,
37    pub end_line: usize,
38}
39
40/// Bounded literal-edit diagnostics; candidate locations come only from matches.
41#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
42#[error(
43    "Literal edit {failure_kind:?} in '{path}'; refresh current source and correct the edit. Use anchored apply_patch to disambiguate. Never retry unchanged failed input."
44)]
45pub struct EditFailure {
46    pub failure_kind: EditFailureKind,
47    pub path: String,
48    pub candidate_locations: Vec<EditLocation>,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub next_read_args: Option<Box<Value>>,
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub next_search_args: Option<Box<Value>>,
53}
54
55impl EditFailure {
56    pub(crate) fn new(kind: EditFailureKind, path: &std::path::Path, locations: Vec<EditLocation>, old: &str) -> Self {
57        let next_read_args = locations.first().map(|location| {
58            let start = location.start_line.saturating_sub(3).max(1);
59            let limit = (location.end_line + 3 - start + 1).min(200);
60            Box::new(json!({"path":path, "offset":start, "limit":limit, "condense":false, "max_tokens":6000}))
61        });
62        let query = old
63            .lines()
64            .find(|line| !line.trim().is_empty())
65            .unwrap_or(old)
66            .trim()
67            .chars()
68            .take(120)
69            .collect::<String>();
70        let next_search_args = next_read_args
71            .is_none()
72            .then_some(query)
73            .filter(|query| !query.is_empty())
74            .map(|query| Box::new(json!({"path":path, "query":query, "max_results":5})));
75        Self {
76            failure_kind: kind,
77            path: path.to_string_lossy().into_owned(),
78            candidate_locations: locations,
79            next_read_args,
80            next_search_args,
81        }
82    }
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct ToolExecutionError {
87    pub tool_name: String,
88    pub error_type: ToolErrorType,
89    pub category: ErrorCategory,
90    pub message: String,
91    pub retryable: bool,
92    pub is_recoverable: bool,
93    pub recovery_suggestions: Vec<Cow<'static, str>>,
94    pub retry_delay_ms: Option<u64>,
95    pub retry_after_ms: Option<u64>,
96    pub circuit_breaker_impact: bool,
97    pub partial_state_possible: bool,
98    pub rollback_performed: bool,
99    pub debug_context: Option<ToolErrorDebugContext>,
100    pub original_error: Option<String>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub patch_failure: Option<PatchFailure>,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub inspection_failure: Option<Value>,
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub edit_failure: Option<EditFailure>,
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
110pub enum ToolErrorType {
111    InvalidParameters,
112    ToolNotFound,
113    PermissionDenied,
114    ResourceNotFound,
115    NetworkError,
116    Timeout,
117    ExecutionError,
118    PolicyViolation,
119}
120
121impl ToolErrorType {
122    /// Return the error type as a static string for serialization.
123    #[must_use]
124    pub const fn as_str(&self) -> &'static str {
125        match self {
126            Self::InvalidParameters => "InvalidParameters",
127            Self::ToolNotFound => "ToolNotFound",
128            Self::PermissionDenied => "PermissionDenied",
129            Self::ResourceNotFound => "ResourceNotFound",
130            Self::NetworkError => "NetworkError",
131            Self::Timeout => "Timeout",
132            Self::ExecutionError => "ExecutionError",
133            Self::PolicyViolation => "PolicyViolation",
134        }
135    }
136}
137
138impl ToolExecutionError {
139    #[inline]
140    #[must_use]
141    pub fn new(tool_name: impl Into<String>, error_type: ToolErrorType, message: impl Into<String>) -> Self {
142        Self::from_parts(tool_name.into(), ErrorCategory::from(error_type), error_type, message.into())
143    }
144
145    /// Construct from a full-fidelity `ErrorCategory`, deriving the lossy
146    /// `error_type` view from it. Prefer this over [`Self::new`] when the
147    /// category came from `vtcode_commons::classify_anyhow_error` /
148    /// `classify_error_message`, so distinctions such as `RateLimit` vs
149    /// `Network` are preserved on the struct.
150    #[must_use]
151    fn from_category(tool_name: impl Into<String>, category: ErrorCategory, message: impl Into<String>) -> Self {
152        Self::from_parts(tool_name.into(), category, ToolErrorType::from(category), message.into())
153    }
154
155    fn from_parts(tool_name: String, category: ErrorCategory, error_type: ToolErrorType, message: String) -> Self {
156        let (retryable, is_recoverable, recovery_suggestions) =
157            generate_recovery_info(tool_name.as_str(), category, error_type);
158
159        Self {
160            tool_name,
161            error_type,
162            category,
163            message,
164            retryable,
165            is_recoverable,
166            recovery_suggestions,
167            retry_delay_ms: None,
168            retry_after_ms: None,
169            circuit_breaker_impact: category.should_trip_circuit_breaker(),
170            partial_state_possible: false,
171            rollback_performed: false,
172            debug_context: None,
173            original_error: None,
174            patch_failure: None,
175            inspection_failure: None,
176            edit_failure: None,
177        }
178    }
179
180    #[inline]
181    #[must_use]
182    pub fn with_original_error(
183        tool_name: impl Into<String>,
184        error_type: ToolErrorType,
185        message: impl Into<String>,
186        original_error: impl Into<String>,
187    ) -> Self {
188        let mut error = Self::new(tool_name, error_type, message);
189        error.original_error = Some(original_error.into());
190        error
191    }
192
193    #[must_use]
194    pub fn from_anyhow(
195        tool_name: impl Into<String>,
196        error: &Error,
197        attempt_index: u32,
198        partial_state_possible: bool,
199        rollback_performed: bool,
200        surface: Option<&str>,
201    ) -> Self {
202        let tool_name = tool_name.into();
203        if let Some(edit) = error.downcast_ref::<EditFailure>() {
204            let mut structured = Self::new(tool_name, ToolErrorType::InvalidParameters, edit.to_string());
205            structured.edit_failure = Some(edit.clone());
206            structured.retryable = false;
207            structured.is_recoverable = true;
208            structured.circuit_breaker_impact = false;
209            structured.recovery_suggestions = vec![Cow::Borrowed(
210                "Use edit_failure.next_read_args when present; otherwise use next_search_args, then read a targeted range at the returned location. One uncached read of at most 200 lines per canonical path/user turn shares the patch recovery quota. Correct old_str or use anchored apply_patch; never retry unchanged failed input.",
211            )];
212            structured = structured.with_attempt(attempt_index);
213            if let Some(surface) = surface {
214                structured = structured.with_surface(surface);
215            }
216            return structured;
217        }
218        if let Some(inspection) = error.downcast_ref::<crate::tools::source_inspection::InspectionRequired>() {
219            let mut structured = Self::new(tool_name, ToolErrorType::InvalidParameters, inspection.to_string());
220            structured.inspection_failure = Some(inspection.details.clone());
221            structured.retryable = false;
222            structured.is_recoverable = true;
223            structured.circuit_breaker_impact = false;
224            structured.recovery_suggestions = vec![Cow::Borrowed(
225                if inspection.details.get("next_read_args").is_some() {
226                    "Use inspection_failure.next_read_args to refresh the missing source, then retry a bounded edit."
227                } else {
228                    "Use apply_patch for authored file changes; mechanical commands require an exact trusted-user exemption."
229                },
230            )];
231            return structured;
232        }
233        // Classify exactly once into the canonical category; the wire-visible
234        // `error_type` is derived from it inside `from_category`.
235        if let Some(session_error) = error.downcast_ref::<crate::tools::exec_session::ExecSessionNotFound>() {
236            let mut structured =
237                Self::from_category(tool_name, ErrorCategory::ResourceNotFound, session_error.to_string())
238                    .with_debug_metadata("failure_code", EXEC_SESSION_NOT_FOUND_CODE);
239            structured.original_error =
240                Some(vtcode_commons::formatting::head_tail_truncate(&format!("{error:#}"), 640, " ... ").0);
241            structured.partial_state_possible = partial_state_possible;
242            structured.rollback_performed = rollback_performed;
243            structured.recovery_suggestions = vec![Cow::Borrowed(
244                "Recover the exact session ID from the original response. Reuse recorded completion output; rerun only if fresh execution is still needed.",
245            )];
246            if let Some(surface) = surface {
247                structured = structured.with_surface(surface);
248            }
249            return structured;
250        }
251        if let Some(patch_error) = error.downcast_ref::<crate::tools::editing::PatchError>() {
252            use crate::tools::editing::PatchError;
253            let category = match patch_error {
254                PatchError::InvalidPath { .. } => ErrorCategory::PolicyViolation,
255                PatchError::MissingFile { .. } => ErrorCategory::ResourceNotFound,
256                PatchError::EmptyInput
257                | PatchError::NoOperations
258                | PatchError::InvalidFormat(_)
259                | PatchError::InvalidHunk { .. } => ErrorCategory::InvalidParameters,
260                PatchError::Io { source, .. } if source.kind() == std::io::ErrorKind::PermissionDenied => {
261                    ErrorCategory::PermissionDenied
262                }
263                _ => ErrorCategory::ExecutionError,
264            };
265            let mut structured = Self::from_category(tool_name.clone(), category, patch_error.to_string());
266            structured.original_error =
267                Some(vtcode_commons::formatting::head_tail_truncate(&format!("{error:#}"), 640, " ... ").0);
268            structured.patch_failure = Some(match patch_error.context_mismatch() {
269                Some((path, evidence)) => PatchFailure::ContextMismatch {
270                    path: path.to_string(),
271                    evidence: vtcode_commons::formatting::head_tail_truncate(evidence, 320, " ... ").0,
272                },
273                None => PatchFailure::Other,
274            });
275            if let Some(path) = structured.patch_context_mismatch_path() {
276                structured.message = format!(
277                    "Patch context mismatch in '{path}': use complete current lines and preserve internal whitespace."
278                );
279                structured.recovery_suggestions = vec![Cow::Borrowed(
280                    "Read the affected path once with a file read limit of 1-200 lines or a single sed -n range, then rebuild apply_patch with complete current context. Never retry the unchanged failed patch. Do not probe matching with scratch edits or inspect the patch implementation. One fresh recovery read per path per turn can pass either read cap; other limits still apply.",
281                )];
282            } else if category == ErrorCategory::InvalidParameters {
283                structured.recovery_suggestions = vec![Cow::Borrowed(
284                    crate::tools::apply_patch::APPLY_PATCH_ARGUMENT_CORRECTION,
285                )];
286            } else {
287                structured.recovery_suggestions = vec![Cow::Borrowed(
288                    "Resolve the reported patch target or filesystem error before retrying; retain permission and workspace boundaries.",
289                )];
290            }
291            structured.retryable = false;
292            structured.is_recoverable =
293                !matches!(category, ErrorCategory::PolicyViolation | ErrorCategory::PermissionDenied);
294            structured.circuit_breaker_impact = false;
295            structured = apply_explicit_error_state(structured, tool_name.as_str(), error);
296            if let Some(surface) = surface {
297                structured = structured.with_surface(surface);
298            }
299            return structured;
300        }
301        let category = vtcode_commons::classify_anyhow_error(error);
302        let mut structured = Self::from_category(tool_name.clone(), category, error.to_string());
303        structured.original_error = Some(format!("{error:#}"));
304        structured =
305            RetryPolicy::default().apply_to_tool_execution_error(structured, attempt_index, Some(tool_name.as_str()));
306        structured.partial_state_possible = partial_state_possible;
307        structured.rollback_performed = rollback_performed;
308        structured = apply_explicit_error_state(structured, tool_name.as_str(), error);
309        if let Some(surface) = surface {
310            structured = structured.with_surface(surface);
311        }
312        structured
313    }
314
315    /// Recovery path for typed patch or literal mismatches; never infer from source text.
316    #[must_use]
317    pub fn patch_context_mismatch_path(&self) -> Option<&str> {
318        match &self.patch_failure {
319            Some(PatchFailure::ContextMismatch { path, .. }) => Some(path),
320            _ => self.edit_failure.as_ref().map(|failure| failure.path.as_str()),
321        }
322    }
323
324    /// Whether a typed runtime lookup failed, independent of quoted text.
325    #[must_use]
326    pub fn is_exec_session_not_found(&self) -> bool {
327        self.category == ErrorCategory::ResourceNotFound
328            && self.debug_context.as_ref().is_some_and(|context| {
329                context
330                    .metadata
331                    .iter()
332                    .any(|(key, value)| key == "failure_code" && value == EXEC_SESSION_NOT_FOUND_CODE)
333            })
334    }
335
336    #[must_use]
337    pub fn policy_violation(tool_name: impl Into<String>, message: impl Into<String>) -> Self {
338        Self::new(tool_name, ToolErrorType::PolicyViolation, message)
339    }
340
341    #[must_use]
342    pub fn with_retry_decision(mut self, decision: RetryDecision) -> Self {
343        self.category = decision.category;
344        // Keep the derived view in lockstep with the authoritative category.
345        // `ToolErrorType::from` is the identity on every ToolErrorType ->
346        // ErrorCategory -> ToolErrorType round trip, so this only changes
347        // `error_type` when the decision actually recategorized the error.
348        self.error_type = ToolErrorType::from(decision.category);
349        self.retryable = decision.retryable;
350        self.retry_delay_ms = decision.delay.map(|delay| delay.as_millis() as u64);
351        self.retry_after_ms = decision.retry_after.map(|delay| delay.as_millis() as u64);
352        self.circuit_breaker_impact = decision.category.should_trip_circuit_breaker();
353        self
354    }
355
356    #[must_use]
357    pub fn with_partial_state(mut self, partial_state_possible: bool, rollback_performed: bool) -> Self {
358        self.partial_state_possible = partial_state_possible;
359        self.rollback_performed = rollback_performed;
360        self
361    }
362
363    #[must_use]
364    pub fn with_surface(mut self, surface: impl Into<String>) -> Self {
365        let debug = self.debug_context.get_or_insert_with(ToolErrorDebugContext::default);
366        debug.surface = Some(surface.into());
367        self
368    }
369
370    #[must_use]
371    pub fn with_attempt(mut self, attempt: u32) -> Self {
372        let debug = self.debug_context.get_or_insert_with(ToolErrorDebugContext::default);
373        debug.attempt = Some(attempt);
374        self
375    }
376
377    #[must_use]
378    pub fn with_invocation_id(mut self, invocation_id: impl Into<String>) -> Self {
379        let debug = self.debug_context.get_or_insert_with(ToolErrorDebugContext::default);
380        debug.invocation_id = Some(invocation_id.into());
381        self
382    }
383
384    #[must_use]
385    pub fn with_debug_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
386        let debug = self.debug_context.get_or_insert_with(ToolErrorDebugContext::default);
387        debug.metadata.push((key.into(), value.into()));
388        self
389    }
390
391    #[must_use]
392    pub fn with_tool_call_context(mut self, tool_name: &str, args: &Value) -> Self {
393        self.tool_name = tool_name.to_string();
394
395        if self.inspection_failure.is_some() || self.edit_failure.is_some() {
396            self.partial_state_possible = false;
397            return self;
398        }
399        if tool_name == crate::config::constants::tools::APPLY_PATCH {
400            return self;
401        }
402
403        let intent = crate::tools::tool_intent::classify_tool_intent(tool_name, args);
404        if intent.mutating || is_command_tool(tool_name) {
405            self.partial_state_possible = true;
406        }
407
408        self
409    }
410
411    #[must_use]
412    pub fn attempts_made(&self) -> Option<u32> {
413        self.debug_context.as_ref().and_then(|context| context.attempt)
414    }
415
416    #[must_use]
417    pub fn retry_summary(&self) -> Option<String> {
418        let retry_count = self.attempts_made().map(|attempts| attempts.saturating_sub(1)).unwrap_or(0);
419
420        let mut summary = if matches!(self.category, ErrorCategory::CircuitOpen) {
421            Some("The service is pausing new calls after repeated transient failures.".to_string())
422        } else if retry_count > 0 {
423            let suffix = if retry_count == 1 { "" } else { "s" };
424            Some(format!("Retried {retry_count} time{suffix} before failing."))
425        } else {
426            None
427        };
428
429        if let Some(delay_ms) = self.retry_after_ms.or(self.retry_delay_ms) {
430            let delay = format_retry_delay(delay_ms);
431            match summary.as_mut() {
432                Some(existing) => {
433                    existing.push(' ');
434                    existing.push_str("Recommended wait: ");
435                    existing.push_str(&delay);
436                    existing.push('.');
437                }
438                None => {
439                    summary = Some(format!("Recommended wait: {delay}."));
440                }
441            }
442        }
443
444        summary
445    }
446
447    #[must_use]
448    pub fn user_message(&self) -> String {
449        let mut message = format!("[{}] {}", self.category.user_label(), self.message);
450
451        if self.rollback_performed {
452            message.push_str(" Any partial changes were rolled back.");
453        } else if self.partial_state_possible {
454            message.push_str(" Partial changes may still exist.");
455        }
456
457        if let Some(retry_summary) = self.retry_summary() {
458            message.push(' ');
459            message.push_str(&retry_summary);
460        }
461
462        if let Some(next_action) = self.recovery_suggestions.first() {
463            message.push_str(" Next: ");
464            message.push_str(next_action.as_ref());
465        }
466
467        message
468    }
469
470    #[must_use]
471    pub fn retry_delay(&self) -> Option<std::time::Duration> {
472        self.retry_delay_ms.map(std::time::Duration::from_millis)
473    }
474
475    #[must_use]
476    pub fn retry_after(&self) -> Option<std::time::Duration> {
477        self.retry_after_ms.map(std::time::Duration::from_millis)
478    }
479
480    #[must_use]
481    pub fn from_tool_output(output: &Value) -> Option<Self> {
482        let error_payload = output.get("error")?;
483        Self::from_error_payload(error_payload)
484    }
485
486    #[must_use]
487    pub fn from_error_payload(error_payload: &Value) -> Option<Self> {
488        if let Some(inner) = error_payload.get("error") {
489            return Self::from_error_payload(inner);
490        }
491
492        if error_payload.is_object() && error_payload.get("message").is_some() {
493            // Single source of truth: deserialize via serde. The previous
494            // implementation hand-built the error struct from individual
495            // fields, which silently dropped any new field that was added to
496            // `ToolExecutionError` (it would fall through to `Self::new`'s
497            // default for that field, which could be subtly wrong).
498            //
499            // `serde_json::from_value` honors every `#[serde(default)]` on
500            // the struct, so a partial payload still reconstructs safely.
501            if let Ok(structured) = serde_json::from_value::<Self>(error_payload.clone()) {
502                return Some(structured);
503            }
504
505            // Last-resort fallback: pull out the minimum fields needed to
506            // construct a usable error. This branch only fires when the
507            // payload is malformed (e.g. wrong type for `category`).
508            let tool_name = error_payload.get("tool_name").and_then(Value::as_str).unwrap_or("tool");
509            let message = error_payload
510                .get("message")
511                .and_then(Value::as_str)
512                .unwrap_or("Unknown tool execution error");
513            let category = error_payload
514                .get("category")
515                .and_then(|value| serde_json::from_value(value.clone()).ok())
516                .unwrap_or_else(|| vtcode_commons::classify_error_message(message));
517            let error_type = error_payload
518                .get("error_type")
519                .and_then(Value::as_str)
520                .map(parse_error_type)
521                .unwrap_or_else(|| ToolErrorType::from(category));
522            let mut structured = Self::new(tool_name.to_string(), error_type, message.to_string());
523            structured.category = category;
524            structured.original_error = error_payload
525                .get("original_error")
526                .and_then(Value::as_str)
527                .map(ToOwned::to_owned);
528            return Some(structured);
529        }
530
531        error_payload.as_str().map(|message| {
532            let category = vtcode_commons::classify_error_message(message);
533            Self::new("tool".to_string(), ToolErrorType::from(category), message.to_string())
534        })
535    }
536
537    #[must_use]
538    pub fn to_json_value(&self) -> Value {
539        json!({
540            "error": {
541                "tool_name": self.tool_name,
542                "error_type": self.error_type.as_str(),
543                "category": self.category,
544                "message": self.message,
545                "retryable": self.retryable,
546                "is_recoverable": self.is_recoverable,
547                "recovery_suggestions": self.recovery_suggestions,
548                "retry_delay_ms": self.retry_delay_ms,
549                "retry_after_ms": self.retry_after_ms,
550                "circuit_breaker_impact": self.circuit_breaker_impact,
551                "partial_state_possible": self.partial_state_possible,
552                "rollback_performed": self.rollback_performed,
553                "debug_context": self.debug_context,
554                "original_error": self.original_error,
555                "patch_failure": self.patch_failure,
556                "inspection_failure": self.inspection_failure,
557                "edit_failure": self.edit_failure,
558            }
559        })
560    }
561}
562
563impl std::fmt::Display for ToolExecutionError {
564    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
565        f.write_str(&self.message)
566    }
567}
568
569impl std::error::Error for ToolExecutionError {}
570
571// Use static string slices to avoid allocations for recovery suggestions.
572// Delegates to the shared `ErrorCategory` recovery suggestions where possible.
573#[inline]
574fn generate_recovery_info(
575    tool_name: &str,
576    category: ErrorCategory,
577    error_type: ToolErrorType,
578) -> (bool, bool, Vec<Cow<'static, str>>) {
579    let is_recoverable = category.is_retryable()
580        || matches!(
581            error_type,
582            ToolErrorType::InvalidParameters | ToolErrorType::PermissionDenied | ToolErrorType::ResourceNotFound
583        );
584    let retryable =
585        category.is_retryable() && !crate::retry::is_non_retryable_command_timeout(category, Some(tool_name));
586    (retryable, is_recoverable, category.recovery_suggestions())
587}
588
589fn parse_error_type(raw: &str) -> ToolErrorType {
590    match raw {
591        "InvalidParameters" => ToolErrorType::InvalidParameters,
592        "ToolNotFound" => ToolErrorType::ToolNotFound,
593        "PermissionDenied" => ToolErrorType::PermissionDenied,
594        "ResourceNotFound" => ToolErrorType::ResourceNotFound,
595        "NetworkError" => ToolErrorType::NetworkError,
596        "Timeout" => ToolErrorType::Timeout,
597        "ExecutionError" => ToolErrorType::ExecutionError,
598        "PolicyViolation" => ToolErrorType::PolicyViolation,
599        _ => ToolErrorType::ExecutionError,
600    }
601}
602
603fn format_retry_delay(delay_ms: u64) -> String {
604    if delay_ms >= 1_000 {
605        format!("{:.1}s", delay_ms as f64 / 1_000.0)
606    } else {
607        format!("{delay_ms}ms")
608    }
609}
610
611fn apply_explicit_error_state(mut error: ToolExecutionError, tool_name: &str, source: &Error) -> ToolExecutionError {
612    if tool_name != crate::config::constants::tools::APPLY_PATCH {
613        return error;
614    }
615
616    if let Some(patch_error) = source.downcast_ref::<crate::tools::editing::PatchError>() {
617        match patch_error {
618            crate::tools::editing::PatchError::RolledBack { .. } => {
619                error = error.with_partial_state(false, true);
620            }
621            crate::tools::editing::PatchError::Recovery { .. } => {
622                error = error.with_partial_state(true, false);
623            }
624            _ => {}
625        }
626    }
627
628    error
629}
630
631// === Bridge conversions between ErrorCategory and ToolErrorType ===
632
633impl From<ErrorCategory> for ToolErrorType {
634    fn from(cat: ErrorCategory) -> Self {
635        match cat {
636            ErrorCategory::InvalidParameters => ToolErrorType::InvalidParameters,
637            ErrorCategory::ToolNotFound => ToolErrorType::ToolNotFound,
638            ErrorCategory::ResourceNotFound => ToolErrorType::ResourceNotFound,
639            ErrorCategory::PermissionDenied => ToolErrorType::PermissionDenied,
640            ErrorCategory::Network | ErrorCategory::ServiceUnavailable => ToolErrorType::NetworkError,
641            ErrorCategory::Timeout => ToolErrorType::Timeout,
642            ErrorCategory::PolicyViolation | ErrorCategory::PlanningPolicyViolation => ToolErrorType::PolicyViolation,
643            ErrorCategory::RateLimit => ToolErrorType::NetworkError,
644            ErrorCategory::CircuitOpen => ToolErrorType::ExecutionError,
645            ErrorCategory::Authentication => ToolErrorType::PermissionDenied,
646            ErrorCategory::SandboxFailure => ToolErrorType::PolicyViolation,
647            ErrorCategory::ResourceExhausted => ToolErrorType::ExecutionError,
648            ErrorCategory::Cancelled => ToolErrorType::ExecutionError,
649            ErrorCategory::ExecutionError => ToolErrorType::ExecutionError,
650        }
651    }
652}
653
654impl From<ToolErrorType> for ErrorCategory {
655    fn from(t: ToolErrorType) -> Self {
656        match t {
657            ToolErrorType::InvalidParameters => ErrorCategory::InvalidParameters,
658            ToolErrorType::ToolNotFound => ErrorCategory::ToolNotFound,
659            ToolErrorType::ResourceNotFound => ErrorCategory::ResourceNotFound,
660            ToolErrorType::PermissionDenied => ErrorCategory::PermissionDenied,
661            ToolErrorType::NetworkError => ErrorCategory::Network,
662            ToolErrorType::Timeout => ErrorCategory::Timeout,
663            ToolErrorType::PolicyViolation => ErrorCategory::PolicyViolation,
664            ToolErrorType::ExecutionError => ErrorCategory::ExecutionError,
665        }
666    }
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672    use anyhow::anyhow;
673
674    fn classify(err: &Error) -> ToolErrorType {
675        ToolErrorType::from(vtcode_commons::classify_anyhow_error(err))
676    }
677
678    #[test]
679    fn classify_error_marks_rate_limit_as_network_error() {
680        let err = anyhow!("provider returned 429 Too Many Requests");
681        assert!(matches!(classify(&err), ToolErrorType::NetworkError));
682    }
683
684    #[test]
685    fn classify_error_marks_service_unavailable_as_network_error() {
686        let err = anyhow!("503 Service Unavailable");
687        assert!(matches!(classify(&err), ToolErrorType::NetworkError));
688    }
689
690    #[test]
691    fn classify_error_marks_weekly_usage_limit_as_execution_error() {
692        let err = anyhow!("you have reached your weekly usage limit");
693        assert!(matches!(classify(&err), ToolErrorType::ExecutionError));
694    }
695
696    #[test]
697    fn classify_error_marks_tool_not_found() {
698        let err = anyhow!("unknown tool: ask_questions");
699        assert!(matches!(classify(&err), ToolErrorType::ToolNotFound));
700    }
701
702    #[test]
703    fn classify_error_marks_policy_violation_before_permission() {
704        let err = anyhow!("tool permission denied by policy");
705        assert!(matches!(classify(&err), ToolErrorType::PolicyViolation));
706    }
707
708    const ALL_TOOL_ERROR_TYPES: [ToolErrorType; 8] = [
709        ToolErrorType::InvalidParameters,
710        ToolErrorType::ToolNotFound,
711        ToolErrorType::PermissionDenied,
712        ToolErrorType::ResourceNotFound,
713        ToolErrorType::NetworkError,
714        ToolErrorType::Timeout,
715        ToolErrorType::ExecutionError,
716        ToolErrorType::PolicyViolation,
717    ];
718
719    #[test]
720    fn error_type_wire_string_round_trips() {
721        for error_type in ALL_TOOL_ERROR_TYPES {
722            assert_eq!(parse_error_type(error_type.as_str()), error_type);
723        }
724    }
725
726    #[test]
727    fn error_type_category_round_trip_is_identity() {
728        // Guarantees `with_retry_decision` never changes error_type unless the
729        // decision actually recategorized the error.
730        for error_type in ALL_TOOL_ERROR_TYPES {
731            assert_eq!(ToolErrorType::from(ErrorCategory::from(error_type)), error_type);
732        }
733    }
734
735    #[test]
736    fn from_anyhow_derives_error_type_from_category() {
737        let err = anyhow!("provider returned 429 Too Many Requests");
738        let structured = ToolExecutionError::from_anyhow("grep_search", &err, 0, false, false, None);
739        assert_eq!(structured.category, ErrorCategory::RateLimit);
740        assert_eq!(structured.error_type, ToolErrorType::from(structured.category));
741    }
742
743    #[test]
744    fn missing_exec_session_classification_survives_context_and_round_trip() {
745        let source = Error::new(crate::tools::exec_session::ExecSessionNotFound { session_id: "run-missing".into() })
746            .context("quoted diagnostic: permission denied in vtcode.toml");
747        let structured = ToolExecutionError::from_anyhow("write_stdin", &source, 0, false, false, Some("registry"));
748        assert_eq!(structured.category, ErrorCategory::ResourceNotFound);
749        assert!(structured.is_exec_session_not_found());
750        assert!(!structured.retryable);
751        assert!(!structured.circuit_breaker_impact);
752        assert_eq!(structured.debug_context.as_ref().unwrap().surface.as_deref(), Some("registry"));
753        assert!(structured.message.contains("reuse its output"));
754        assert!(!structured.message.contains("re-run the command instead of waiting"));
755        let decoded = ToolExecutionError::from_error_payload(&structured.to_json_value()).unwrap();
756        assert!(decoded.is_exec_session_not_found());
757
758        let lookalike = anyhow!("exec session 'run-missing' not found: permission denied");
759        let untyped = ToolExecutionError::from_anyhow("write_stdin", &lookalike, 0, false, false, None);
760        assert_eq!(untyped.category, ErrorCategory::PermissionDenied);
761        assert!(!untyped.is_exec_session_not_found());
762    }
763
764    #[test]
765    fn retryable_matches_error_category_predicate() {
766        for error_type in ALL_TOOL_ERROR_TYPES {
767            let category = ErrorCategory::from(error_type);
768            let structured = ToolExecutionError::new("grep_search".to_string(), error_type, "boom".to_string());
769            assert_eq!(structured.retryable, category.is_retryable(), "retryable mismatch for {error_type:?}");
770        }
771    }
772
773    #[test]
774    fn command_tool_timeouts_are_not_retryable() {
775        let structured = ToolExecutionError::new(
776            crate::config::constants::tools::CREATE_PTY_SESSION.to_string(),
777            ToolErrorType::Timeout,
778            "command timed out".to_string(),
779        );
780        assert!(!structured.retryable);
781    }
782
783    #[test]
784    fn tool_call_context_marks_mutating_tools_as_partial_state_possible() {
785        let error = ToolExecutionError::new(
786            "write_file".to_string(),
787            ToolErrorType::ExecutionError,
788            "write failed".to_string(),
789        )
790        .with_tool_call_context(
791            crate::config::constants::tools::WRITE_FILE,
792            &serde_json::json!({"path": "note.txt", "content": "hello"}),
793        );
794
795        assert!(error.partial_state_possible);
796        assert!(!error.rollback_performed);
797    }
798
799    #[test]
800    fn tool_call_context_marks_apply_patch_failures_as_rolled_back() {
801        let source = Error::new(crate::tools::editing::PatchError::RolledBack {
802            original: Box::new(crate::tools::editing::PatchError::SegmentNotFound {
803                path: "src/lib.rs".to_string(),
804                snippet: "fn main()".to_string(),
805            }),
806        });
807        let error = ToolExecutionError::from_anyhow(
808            crate::config::constants::tools::APPLY_PATCH,
809            &source,
810            0,
811            false,
812            false,
813            None,
814        );
815
816        assert!(!error.partial_state_possible);
817        assert!(error.rollback_performed);
818    }
819
820    #[test]
821    fn user_message_includes_retry_summary_and_wait() {
822        let mut error = ToolExecutionError::new(
823            crate::config::constants::tools::READ_FILE.to_string(),
824            ToolErrorType::ExecutionError,
825            "read failed".to_string(),
826        )
827        .with_attempt(2);
828        error.retry_delay_ms = Some(1_500);
829
830        let message = error.user_message();
831
832        assert!(message.contains("Retried 1 time before failing."));
833        assert!(message.contains("Recommended wait: 1.5s."));
834    }
835}