Skip to main content

vtcode_commons/
error_category.rs

1#![expect(
2    clippy::let_underscore_must_use,
3    reason = "The category formatter intentionally ignores infallible formatting results."
4)]
5
6//! Unified error categorization system for consistent error classification across VT Code.
7//!
8//! This module provides a single canonical `ErrorCategory` enum that unifies the
9//! previously separate classification systems in `registry::error` (8-variant `ToolErrorType`)
10//! and `unified_error` (16-variant `UnifiedErrorKind`). Both systems now map through
11//! this shared taxonomy for consistent retry decisions and error handling.
12//!
13//! # Error Categories
14//!
15//! Errors are divided into **retryable** (transient) and **non-retryable** (permanent)
16//! categories, with sub-classifications for specific handling strategies.
17//!
18//! # Design Decisions
19//!
20//! - String-based fallback is preserved only for `anyhow::Error` chains where the
21//!   original type is erased. Typed `From` conversions are preferred.
22//! - Policy violations are explicitly separated from OS-level permission denials.
23//! - Rate limiting is a distinct category (not merged with network errors).
24//! - Circuit breaker open is categorized separately for recovery flow routing.
25
26use std::borrow::Cow;
27use std::fmt;
28use std::fmt::Write;
29use std::time::Duration;
30
31/// Canonical error category used throughout VT Code for consistent
32/// retry decisions, user-facing messages, and error handling strategies.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
34pub enum ErrorCategory {
35    // === Retryable (Transient) ===
36    /// Network connectivity issue (connection reset, DNS failure, etc.)
37    Network,
38    /// Request timed out or deadline exceeded
39    Timeout,
40    /// Rate limit exceeded (HTTP 429, provider throttling)
41    RateLimit,
42    /// External service temporarily unavailable (HTTP 5xx)
43    ServiceUnavailable,
44    /// Circuit breaker is open for this tool/service
45    CircuitOpen,
46
47    // === Non-Retryable (Permanent) ===
48    /// Authentication or authorization failure (invalid API key, expired token)
49    Authentication,
50    /// Invalid parameters, arguments, or schema validation failure
51    InvalidParameters,
52    /// Tool not found or unavailable
53    ToolNotFound,
54    /// Resource not found (file, directory, path does not exist)
55    ResourceNotFound,
56    /// OS-level permission denied (file permissions, EACCES, EPERM)
57    PermissionDenied,
58    /// Policy violation (workspace boundary, tool deny policy, safety gate)
59    PolicyViolation,
60    /// Planning workflow violation (mutating tool without read-only capabilities)
61    PlanningPolicyViolation,
62    /// Sandbox execution failure
63    SandboxFailure,
64    /// Resource exhausted (quota, billing, spending limit, disk, memory)
65    ResourceExhausted,
66    /// User cancelled the operation
67    Cancelled,
68    /// General execution error (catch-all for unclassified failures)
69    ExecutionError,
70}
71
72/// Describes whether and how an error can be retried.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum Retryability {
75    /// Error is transient and may succeed on retry.
76    Retryable {
77        /// Suggested maximum retry attempts.
78        max_attempts: u32,
79        /// Suggested backoff strategy.
80        backoff: BackoffStrategy,
81    },
82    /// Error is permanent and should NOT be retried.
83    NonRetryable,
84    /// Error requires human intervention before proceeding.
85    RequiresIntervention,
86}
87
88/// Backoff strategy for retryable errors.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum BackoffStrategy {
91    /// Exponential backoff with base delay and maximum cap.
92    Exponential { base: Duration, max: Duration },
93    /// Fixed delay between retries (e.g., for rate-limited APIs with Retry-After).
94    Fixed(Duration),
95}
96
97impl ErrorCategory {
98    /// Return the category as a `&'static str`, avoiding the temporary
99    /// `String` allocation that `to_string()` (via `Display`) would create.
100    /// Equivalent to `user_label()` but named to match the conventional
101    /// `as_str()` accessor used across the codebase.
102    #[inline]
103    #[must_use]
104    pub const fn as_str(&self) -> &'static str {
105        self.user_label()
106    }
107
108    /// Whether this error category is safe to retry.
109    #[inline]
110    #[must_use]
111    pub const fn is_retryable(&self) -> bool {
112        matches!(
113            self,
114            ErrorCategory::Network
115                | ErrorCategory::Timeout
116                | ErrorCategory::RateLimit
117                | ErrorCategory::ServiceUnavailable
118                | ErrorCategory::CircuitOpen
119        )
120    }
121
122    /// Whether this category should count toward circuit breaker transitions.
123    #[inline]
124    #[must_use]
125    pub const fn should_trip_circuit_breaker(&self) -> bool {
126        matches!(
127            self,
128            ErrorCategory::Network
129                | ErrorCategory::Timeout
130                | ErrorCategory::RateLimit
131                | ErrorCategory::ServiceUnavailable
132                | ErrorCategory::ExecutionError
133        )
134    }
135
136    /// Whether this error is an LLM argument mistake (should not count toward
137    /// circuit breaker thresholds).
138    #[inline]
139    #[must_use]
140    const fn is_llm_mistake(&self) -> bool {
141        matches!(self, ErrorCategory::InvalidParameters)
142    }
143
144    /// Whether this error represents a permanent, non-recoverable condition.
145    #[inline]
146    #[must_use]
147    pub const fn is_permanent(&self) -> bool {
148        matches!(
149            self,
150            ErrorCategory::Authentication
151                | ErrorCategory::PolicyViolation
152                | ErrorCategory::PlanningPolicyViolation
153                | ErrorCategory::ResourceExhausted
154        )
155    }
156
157    /// Get the recommended retryability for this error category.
158    #[must_use]
159    pub fn retryability(&self) -> Retryability {
160        match self {
161            ErrorCategory::Network | ErrorCategory::ServiceUnavailable => Retryability::Retryable {
162                max_attempts: 3,
163                backoff: BackoffStrategy::Exponential {
164                    base: Duration::from_millis(500),
165                    max: Duration::from_secs(10),
166                },
167            },
168            ErrorCategory::Timeout => Retryability::Retryable {
169                max_attempts: 2,
170                backoff: BackoffStrategy::Exponential {
171                    base: Duration::from_millis(1000),
172                    max: Duration::from_secs(15),
173                },
174            },
175            ErrorCategory::RateLimit => Retryability::Retryable {
176                max_attempts: 3,
177                backoff: BackoffStrategy::Exponential {
178                    base: Duration::from_secs(1),
179                    max: Duration::from_secs(30),
180                },
181            },
182            ErrorCategory::CircuitOpen => Retryability::Retryable {
183                max_attempts: 1,
184                backoff: BackoffStrategy::Fixed(Duration::from_secs(10)),
185            },
186            ErrorCategory::PermissionDenied => Retryability::RequiresIntervention,
187            _ => Retryability::NonRetryable,
188        }
189    }
190
191    /// Get recovery suggestions for this error category.
192    /// Returns static strings to avoid allocation.
193    #[must_use]
194    pub fn recovery_suggestions(&self) -> Vec<Cow<'static, str>> {
195        match self {
196            ErrorCategory::Network => vec![
197                Cow::Borrowed("Check network connectivity"),
198                Cow::Borrowed("Retry the operation after a brief delay"),
199                Cow::Borrowed("Verify external service availability"),
200            ],
201            ErrorCategory::Timeout => vec![
202                Cow::Borrowed("Increase timeout values if appropriate"),
203                Cow::Borrowed("Break large operations into smaller chunks"),
204                Cow::Borrowed("Check system resources and performance"),
205            ],
206            ErrorCategory::RateLimit => vec![
207                Cow::Borrowed("Wait before retrying the request"),
208                Cow::Borrowed("Reduce request frequency"),
209                Cow::Borrowed("Check provider rate limit documentation"),
210            ],
211            ErrorCategory::ServiceUnavailable => vec![
212                Cow::Borrowed("The service is temporarily unavailable"),
213                Cow::Borrowed("Retry after a brief delay"),
214                Cow::Borrowed("Check service status page if available"),
215            ],
216            ErrorCategory::CircuitOpen => vec![
217                Cow::Borrowed("This tool has been temporarily disabled due to repeated failures"),
218                Cow::Borrowed("Wait for the circuit breaker cooldown period"),
219                Cow::Borrowed("Try an alternative approach"),
220            ],
221            ErrorCategory::Authentication => vec![
222                Cow::Borrowed("Verify your API key or credentials"),
223                Cow::Borrowed("Check that your account is active and has sufficient permissions"),
224                Cow::Borrowed("Ensure environment variables for API keys are set correctly"),
225            ],
226            ErrorCategory::InvalidParameters => vec![
227                Cow::Borrowed("Check parameter names and types against the tool schema"),
228                Cow::Borrowed("Ensure required parameters are provided"),
229                Cow::Borrowed("Verify parameter values are within acceptable ranges"),
230            ],
231            ErrorCategory::ToolNotFound => vec![
232                Cow::Borrowed("Verify the tool name is spelled correctly"),
233                Cow::Borrowed("Check if the tool is available in the current context"),
234            ],
235            ErrorCategory::ResourceNotFound => vec![
236                Cow::Borrowed("Verify file paths and resource locations"),
237                Cow::Borrowed("Check if files exist and are accessible"),
238                Cow::Borrowed("Use list_dir to explore available resources"),
239            ],
240            ErrorCategory::PermissionDenied => vec![
241                Cow::Borrowed("Check file permissions and access rights"),
242                Cow::Borrowed("Ensure workspace boundaries are respected"),
243            ],
244            ErrorCategory::PolicyViolation => vec![
245                Cow::Borrowed("Review workspace policies and restrictions"),
246                Cow::Borrowed("Use alternative tools that comply with policies"),
247            ],
248            ErrorCategory::PlanningPolicyViolation => vec![
249                Cow::Borrowed("This operation is not allowed in the Planning workflow with read-only permissions"),
250                Cow::Borrowed("Exit the Planning workflow to perform mutating operations"),
251            ],
252            ErrorCategory::SandboxFailure => vec![
253                Cow::Borrowed("The sandbox denied this operation"),
254                Cow::Borrowed("Check sandbox configuration and permissions"),
255            ],
256            ErrorCategory::ResourceExhausted => vec![
257                Cow::Borrowed("Check your account usage limits and billing status"),
258                Cow::Borrowed("Review resource consumption and optimize if possible"),
259            ],
260            ErrorCategory::Cancelled => vec![Cow::Borrowed("The operation was cancelled")],
261            ErrorCategory::ExecutionError => vec![
262                Cow::Borrowed("Review error details for specific issues"),
263                Cow::Borrowed("Check tool documentation for known limitations"),
264            ],
265        }
266    }
267
268    /// Get a concise, user-friendly label for this error category.
269    #[must_use]
270    pub const fn user_label(&self) -> &'static str {
271        match self {
272            ErrorCategory::Network => "Network error",
273            ErrorCategory::Timeout => "Request timed out",
274            ErrorCategory::RateLimit => "Rate limit exceeded",
275            ErrorCategory::ServiceUnavailable => "Service temporarily unavailable",
276            ErrorCategory::CircuitOpen => "Tool temporarily disabled",
277            ErrorCategory::Authentication => "Authentication failed",
278            ErrorCategory::InvalidParameters => "Invalid parameters",
279            ErrorCategory::ToolNotFound => "Tool not found",
280            ErrorCategory::ResourceNotFound => "Resource not found",
281            ErrorCategory::PermissionDenied => "Permission denied",
282            ErrorCategory::PolicyViolation => "Blocked by policy",
283            ErrorCategory::PlanningPolicyViolation => "Not allowed in planning workflow",
284            ErrorCategory::SandboxFailure => "Sandbox denied",
285            ErrorCategory::ResourceExhausted => "Resource limit reached",
286            ErrorCategory::Cancelled => "Operation cancelled",
287            ErrorCategory::ExecutionError => "Execution failed",
288        }
289    }
290
291    /// Build actionable guidance for authentication errors.
292    ///
293    /// Returns a single combined line directing the user to `/secret` (API-key
294    /// providers) or `/login` (managed-auth providers). Env-var guidance is
295    /// intentionally omitted — secure storage via `/secret` is the canonical
296    /// path and env vars are a fallback only.
297    ///
298    /// `has_stored_credential` distinguishes "no key at all" (needs `/secret add`)
299    /// from "key stored but rejected by the API" (needs verification).
300    #[must_use]
301    pub fn auth_recovery_guidance(
302        &self,
303        provider_label: &str,
304        provider_key: &str,
305        is_managed_auth: bool,
306        has_stored_credential: bool,
307    ) -> Vec<String> {
308        if !matches!(self, ErrorCategory::Authentication) {
309            return vec![];
310        }
311
312        if is_managed_auth {
313            vec![format!(
314                "Authentication failed for {provider_label}. Run /login {provider_key} to re-authenticate."
315            )]
316        } else if has_stored_credential {
317            vec![format!(
318                "Authentication failed for {provider_label}. The stored API key was rejected — run /secret add {provider_key} to replace it with a valid key."
319            )]
320        } else {
321            vec![format!(
322                "Authentication failed for {provider_label}. Run /secret add {provider_key} to store your API key in secure storage (OS keyring or encrypted file)."
323            )]
324        }
325    }
326}
327
328impl fmt::Display for ErrorCategory {
329    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
330        f.write_str(self.user_label())
331    }
332}
333
334// ---------------------------------------------------------------------------
335// Classify from anyhow::Error (string-based fallback for erased types)
336// ---------------------------------------------------------------------------
337
338/// Classify an `anyhow::Error` into a canonical `ErrorCategory`.
339///
340/// This uses string matching as a last resort when the original error type has
341/// been erased through `anyhow` wrapping. Typed conversions (e.g., `From<LLMError>`)
342/// should be preferred where the original error type is available.
343///
344/// Recovery decisions are made by *type* when it survives the `anyhow`
345/// boundary. The outermost context layer is classified first (preserving the
346/// intentional wording of a deliberate `.context(...)`), but when that layer is
347/// uninformative we consult the typed root and then the remaining cause chain.
348/// Without this, a wrapping `.context("...")` message alone would hide the
349/// underlying category — e.g. an `LLMError::RateLimit` buried under
350/// `"compaction failed"` would fall through to `ExecutionError` and lose its
351/// retry/recovery policy.
352#[must_use]
353pub fn classify_anyhow_error(err: &anyhow::Error) -> ErrorCategory {
354    // Fast path: the outermost message is usually the most specific context and
355    // matches the historical behavior exactly.
356    let outer = classify_error_message(&err.to_string());
357    if outer != ErrorCategory::ExecutionError {
358        return outer;
359    }
360
361    // The outer context was uninformative (or absent). Prefer the typed source
362    // of truth when it survived the boundary, then fall back to the deeper
363    // cause chain (`innermost` markers such as `connection reset`).
364    if let Some(llm_error) = err.downcast_ref::<crate::llm::LLMError>() {
365        return ErrorCategory::from(llm_error);
366    }
367
368    for cause in err.chain().skip(1) {
369        let category = classify_error_message(&cause.to_string());
370        if category != ErrorCategory::ExecutionError {
371            return category;
372        }
373    }
374
375    ErrorCategory::ExecutionError
376}
377
378/// Return whether an error explicitly reports that the provider rejected the
379/// request because its input context is too large.
380///
381/// Context-capacity failures are intentionally kept separate from
382/// [`ErrorCategory::InvalidParameters`]: they are not model argument mistakes
383/// and a bounded history compaction can make the same request valid. The
384/// marker set is deliberately specific so ordinary validation messages that
385/// merely mention a context field do not enter recovery.
386#[must_use]
387pub fn is_context_capacity_error(err: &anyhow::Error) -> bool {
388    err.chain().any(|cause| is_context_capacity_message(&cause.to_string()))
389}
390
391/// Return whether a provider error message identifies an input-context limit.
392#[must_use]
393pub fn is_context_capacity_message(message: &str) -> bool {
394    let message = message.to_ascii_lowercase();
395    contains_any(
396        &message,
397        &[
398            "context_length_exceeded",
399            "context length exceeded",
400            "maximum context length",
401            "maximum context window",
402            "context window exceeded",
403            "context window is too small",
404            "exceeds the model's maximum context",
405            "exceeds model context",
406            "exceeds the maximum context",
407            "prompt is too long",
408            "input is too long",
409            "input token count exceeds",
410            "exceeds the maximum number of tokens",
411            "maximum input tokens",
412            "too many tokens in the prompt",
413            "request exceeds the context",
414            // Merge-gateway / Anthropic / OpenAI variants observed on
415            // near-budget follow-ups (e.g. ~956k prompt on a 1M budget) that
416            // previously fell through to generic `ExecutionError` and missed
417            // the compacting tool-enabled recovery path.
418            "context limit exceeded",
419            "context size exceeded",
420            "exceeds context",
421            "input tokens exceed",
422            "prompt tokens exceed",
423            "prompt token count exceeds",
424            "token limit exceeded",
425            "input too large",
426            "prompt too large",
427        ],
428    )
429}
430
431/// Classify an error message string into an `ErrorCategory`.
432///
433/// Marker groups are checked in priority order to handle overlapping patterns
434/// (e.g., "tool permission denied by policy" → `PolicyViolation`, not `PermissionDenied`).
435#[inline]
436#[must_use]
437pub fn classify_error_message(msg: &str) -> ErrorCategory {
438    let msg = if msg.as_bytes().iter().any(|b| b.is_ascii_uppercase()) {
439        Cow::Owned(msg.to_ascii_lowercase())
440    } else {
441        Cow::Borrowed(msg)
442    };
443
444    // --- Priority 1: Policy violations (before permission checks) ---
445    if contains_any(
446        &msg,
447        &[
448            "policy violation",
449            "denied by policy",
450            "tool permission denied",
451            "safety validation failed",
452            "not allowed in planning workflow",
453            "only available when planning workflow is active",
454            "workspace boundary",
455            "blocked by policy",
456        ],
457    ) {
458        return ErrorCategory::PolicyViolation;
459    }
460
461    // --- Priority 2: Planning workflow violations ---
462    if contains_any(
463        &msg,
464        &[
465            "planning workflow",
466            "read-only permissions",
467            concat!("read-only ", "mode"),
468            "planning_policy_violation",
469        ],
470    ) {
471        return ErrorCategory::PlanningPolicyViolation;
472    }
473
474    // --- Priority 3: Authentication / Authorization ---
475    if contains_any(
476        &msg,
477        &[
478            "invalid api key",
479            "authentication failed",
480            "unauthorized",
481            "401",
482            "invalid credentials",
483        ],
484    ) {
485        return ErrorCategory::Authentication;
486    }
487
488    // --- Priority 4: Non-retryable resource exhaustion (billing, quotas) ---
489    if contains_any(
490        &msg,
491        &[
492            "weekly usage limit",
493            "daily usage limit",
494            "monthly spending limit",
495            "insufficient credits",
496            "quota exceeded",
497            "billing",
498            "payment required",
499        ],
500    ) {
501        return ErrorCategory::ResourceExhausted;
502    }
503
504    // --- Priority 5: Invalid parameters ---
505    if contains_any(
506        &msg,
507        &[
508            "invalid argument",
509            "invalid parameters",
510            "invalid type",
511            "malformed",
512            "failed to parse arguments",
513            "failed to parse argument",
514            "missing required",
515            "at least one item is required",
516            "is required for",
517            "schema validation",
518            "argument validation failed",
519            "unknown field",
520            "unknown variant",
521            "expected struct",
522            "expected enum",
523            "type mismatch",
524            "must be an absolute path",
525            "not parseable",
526            "parseable as",
527            // Patch format errors — these are LLM argument mistakes (the model
528            // sent a malformed patch), not execution failures. Classifying them
529            // as InvalidParameters ensures they don't trip the circuit breaker
530            // (is_llm_mistake() == true) and get parameter-focused recovery
531            // suggestions. See checkpoint turn_615 for the failure this
532            // prevents: a unified-diff patch was classified as ExecutionError
533            // and got generic "check tool documentation" suggestions.
534            "invalid patch format",
535            "invalid patch hunk",
536            "invalid patch operation",
537            "cannot parse empty patch",
538            "patch does not contain",
539            "semantic patch anchor",
540        ],
541    ) {
542        return ErrorCategory::InvalidParameters;
543    }
544
545    // --- Priority 6: Tool not found ---
546    if contains_any(&msg, &["tool not found", "unknown tool", "unsupported tool", "no such tool"]) {
547        return ErrorCategory::ToolNotFound;
548    }
549
550    // --- Priority 7: Resource not found ---
551    if contains_any(
552        &msg,
553        &[
554            "no such file",
555            "no such directory",
556            "file not found",
557            "directory not found",
558            "resource not found",
559            "path not found",
560            "does not exist",
561            "enoent",
562        ],
563    ) {
564        return ErrorCategory::ResourceNotFound;
565    }
566
567    // --- Priority 8: Permission denied (OS-level) ---
568    if contains_any(
569        &msg,
570        &[
571            "permission denied",
572            "access denied",
573            "operation not permitted",
574            "eacces",
575            "eperm",
576            "forbidden",
577            "403",
578        ],
579    ) {
580        return ErrorCategory::PermissionDenied;
581    }
582
583    // --- Priority 9: Cancellation ---
584    if contains_any(&msg, &["cancelled", "interrupted", "canceled"]) {
585        return ErrorCategory::Cancelled;
586    }
587
588    // --- Priority 10: Circuit breaker ---
589    if contains_any(&msg, &["circuit breaker", "circuit open"]) {
590        return ErrorCategory::CircuitOpen;
591    }
592
593    // --- Priority 11: Sandbox ---
594    if contains_any(&msg, &["sandbox denied", "sandbox failure"]) {
595        return ErrorCategory::SandboxFailure;
596    }
597
598    // --- Priority 12: Rate limiting (before general network) ---
599    if contains_any(&msg, &["rate limit", "too many requests", "429", "throttl"]) {
600        return ErrorCategory::RateLimit;
601    }
602
603    // --- Priority 13: Timeout ---
604    if contains_any(&msg, &["timeout", "timed out", "deadline exceeded"]) {
605        return ErrorCategory::Timeout;
606    }
607
608    // --- Priority 14: Provider transient response-shape failures ---
609    if contains_any(
610        &msg,
611        &[
612            "invalid response format: missing choices",
613            "invalid response format: missing message",
614            "missing choices in response",
615            "missing message in choice",
616            "no choices in response",
617            "invalid response from ",
618            "empty response body",
619            "response did not contain",
620            "unexpected response format",
621            "failed to parse response",
622            // Post-tool follow-up streaming failures surface here when the
623            // provider drops the SSE/event stream mid-response. Without these
624            // markers the follow-up is misclassified as generic
625            // `ExecutionError` (non-retryable) and the turn ends instead of
626            // scheduling the bounded tool-free retry.
627            "stream disconnected",
628            "stream closed unexpectedly",
629            "incomplete stream",
630            "unexpected end of stream",
631            "truncated response",
632            "failed to decode stream",
633            "stream terminated",
634        ],
635    ) {
636        return ErrorCategory::ServiceUnavailable;
637    }
638
639    // --- Priority 15: Service unavailable (HTTP 5xx and related) ---
640    if contains_any(
641        &msg,
642        &[
643            "service unavailable",
644            "temporarily unavailable",
645            "internal server error",
646            "bad gateway",
647            "gateway timeout",
648            "overloaded",
649            "500",
650            "502",
651            "503",
652            "504",
653        ],
654    ) {
655        return ErrorCategory::ServiceUnavailable;
656    }
657
658    // --- Priority 16: Network (connectivity, DNS, transport) ---
659    if contains_any(
660        &msg,
661        &[
662            "network",
663            "connection reset",
664            "connection refused",
665            "broken pipe",
666            "dns",
667            "name resolution",
668            "try again",
669            "retry later",
670            "upstream connect error",
671            "tls handshake",
672            "socket hang up",
673            "econnreset",
674            "etimedout",
675            // reqwest surfaces truncated/aborted response streams as a body
676            // decode failure; Ollama (cloud) emits this on transient drops.
677            // It is a transport error, not a payload problem — retryable.
678            "error decoding response body",
679            // Transport-level stream teardown during a follow-up request.
680            // Distinct from response-shape markers above: the connection
681            // itself failed, not the payload. `stream error` stays specific
682            // (`received`) so `downstream error` prose does not match.
683            "connection closed before",
684            "stream reset",
685            "stream error received",
686        ],
687    ) {
688        return ErrorCategory::Network;
689    }
690
691    // --- Priority 17: Resource exhausted (memory, disk) ---
692    if contains_any(&msg, &["out of memory", "disk full", "no space left"]) {
693        return ErrorCategory::ResourceExhausted;
694    }
695
696    // --- Fallback ---
697    ErrorCategory::ExecutionError
698}
699
700/// Check if an LLM error message is retryable (used by the LLM request retry loop).
701///
702/// This is a focused classifier for LLM provider errors, combining
703/// non-retryable and retryable marker checks for the request retry path.
704#[inline]
705#[must_use]
706pub fn is_retryable_llm_error_message(msg: &str) -> bool {
707    let category = classify_error_message(msg);
708    category.is_retryable()
709}
710
711#[inline]
712fn contains_any(message: &str, markers: &[&str]) -> bool {
713    markers.iter().any(|marker| message.contains(marker))
714}
715
716// ---------------------------------------------------------------------------
717// Typed conversions from known error types
718// ---------------------------------------------------------------------------
719
720impl From<&crate::llm::LLMError> for ErrorCategory {
721    fn from(err: &crate::llm::LLMError) -> Self {
722        match err {
723            crate::llm::LLMError::Authentication { .. } => ErrorCategory::Authentication,
724            crate::llm::LLMError::RateLimit { metadata } => {
725                classify_llm_metadata(metadata.as_deref(), ErrorCategory::RateLimit)
726            }
727            crate::llm::LLMError::InvalidRequest { .. } => ErrorCategory::InvalidParameters,
728            crate::llm::LLMError::Network { .. } => ErrorCategory::Network,
729            crate::llm::LLMError::Provider { message, metadata } => {
730                let metadata_category = classify_llm_metadata(metadata.as_deref(), ErrorCategory::ExecutionError);
731                if metadata_category != ErrorCategory::ExecutionError {
732                    return metadata_category;
733                }
734
735                // Check metadata status code first for precise classification
736                if let Some(meta) = metadata
737                    && let Some(status) = meta.status
738                {
739                    return match status {
740                        401 => ErrorCategory::Authentication,
741                        403 => ErrorCategory::PermissionDenied,
742                        404 => ErrorCategory::ResourceNotFound,
743                        429 => ErrorCategory::RateLimit,
744                        400 => ErrorCategory::InvalidParameters,
745                        500 | 502 | 503 | 504 => ErrorCategory::ServiceUnavailable,
746                        408 => ErrorCategory::Timeout,
747                        _ => classify_error_message(message),
748                    };
749                }
750                // Fall back to message-based classification
751                classify_error_message(message)
752            }
753        }
754    }
755}
756
757fn classify_llm_metadata(metadata: Option<&crate::llm::LLMErrorMetadata>, fallback: ErrorCategory) -> ErrorCategory {
758    let Some(metadata) = metadata else {
759        return fallback;
760    };
761
762    let mut hint = String::new();
763    if let Some(code) = &metadata.code {
764        hint.push_str(code);
765        hint.push(' ');
766    }
767    if let Some(message) = &metadata.message {
768        hint.push_str(message);
769        hint.push(' ');
770    }
771    if let Some(status) = metadata.status {
772        let _ = write!(&mut hint, "{status}");
773    }
774
775    let classified = classify_error_message(&hint);
776    if classified == ErrorCategory::ExecutionError {
777        fallback
778    } else {
779        classified
780    }
781}
782
783#[cfg(test)]
784mod tests {
785    use super::*;
786
787    // --- classify_error_message tests ---
788
789    #[test]
790    fn policy_violation_takes_priority_over_permission() {
791        assert_eq!(classify_error_message("tool permission denied by policy"), ErrorCategory::PolicyViolation);
792    }
793
794    #[test]
795    fn rate_limit_classified_correctly() {
796        assert_eq!(classify_error_message("provider returned 429 Too Many Requests"), ErrorCategory::RateLimit);
797        assert_eq!(classify_error_message("rate limit exceeded"), ErrorCategory::RateLimit);
798    }
799
800    #[test]
801    fn service_unavailable_is_classified() {
802        assert_eq!(classify_error_message("503 service unavailable"), ErrorCategory::ServiceUnavailable);
803    }
804
805    #[test]
806    fn follow_up_stream_failures_are_retryable() {
807        // Post-tool follow-up streaming drops must not fall through to
808        // generic `ExecutionError`: both shapes are retryable.
809        for msg in [
810            "follow-up failed: stream disconnected mid-response",
811            "sse stream terminated before completion",
812            "incomplete stream while reading follow-up",
813            "connection closed before response completed",
814            "stream reset by peer during follow-up",
815            "h2 stream error received: internal error",
816        ] {
817            let category = classify_error_message(msg);
818            assert!(category.is_retryable(), "{msg} -> {category:?} should be retryable");
819        }
820        // Asymmetric boundary: unrelated `stream` prose without a failure
821        // marker stays non-retryable, and `downstream error` must not match
822        // the h2 `stream error received` marker.
823        assert_eq!(classify_error_message("upstream stream file ready"), ErrorCategory::ExecutionError);
824        assert_eq!(classify_error_message("downstream error: 400 bad request"), ErrorCategory::ExecutionError);
825        assert_eq!(classify_error_message("something went wrong"), ErrorCategory::ExecutionError);
826    }
827
828    #[test]
829    fn authentication_errors() {
830        assert_eq!(classify_error_message("invalid api key provided"), ErrorCategory::Authentication);
831        assert_eq!(classify_error_message("401 unauthorized"), ErrorCategory::Authentication);
832    }
833
834    #[test]
835    fn billing_errors_are_resource_exhausted() {
836        assert_eq!(
837            classify_error_message("you have reached your weekly usage limit"),
838            ErrorCategory::ResourceExhausted
839        );
840        assert_eq!(classify_error_message("quota exceeded for this model"), ErrorCategory::ResourceExhausted);
841    }
842
843    #[test]
844    fn timeout_errors() {
845        assert_eq!(classify_error_message("connection timeout"), ErrorCategory::Timeout);
846        assert_eq!(classify_error_message("request timed out after 30s"), ErrorCategory::Timeout);
847    }
848
849    #[test]
850    fn network_errors() {
851        assert_eq!(classify_error_message("connection reset by peer"), ErrorCategory::Network);
852        assert_eq!(classify_error_message("dns name resolution failed"), ErrorCategory::Network);
853    }
854
855    #[test]
856    fn tool_not_found() {
857        assert_eq!(classify_error_message("unknown tool: ask_questions"), ErrorCategory::ToolNotFound);
858    }
859
860    #[test]
861    fn resource_not_found() {
862        assert_eq!(classify_error_message("no such file or directory: /tmp/missing"), ErrorCategory::ResourceNotFound);
863        assert_eq!(
864            classify_error_message("Path 'crates/codegen/vtcode-core/src/agent' does not exist"),
865            ErrorCategory::ResourceNotFound
866        );
867    }
868
869    #[test]
870    fn patch_format_errors_are_invalid_parameters() {
871        // Patch format errors are LLM argument mistakes, not execution
872        // failures. They must classify as InvalidParameters (is_llm_mistake
873        // == true, no circuit breaker trip) so the model gets parameter-
874        // focused recovery suggestions instead of generic "check tool docs".
875        assert_eq!(
876            classify_error_message("invalid patch format: missing '*** Begin Patch' marker"),
877            ErrorCategory::InvalidParameters
878        );
879        assert_eq!(
880            classify_error_message("invalid patch format: input looks like a standard unified diff (---/+++ format)"),
881            ErrorCategory::InvalidParameters
882        );
883        assert_eq!(
884            classify_error_message("invalid patch hunk on line 5: unexpected end of input"),
885            ErrorCategory::InvalidParameters
886        );
887        assert_eq!(classify_error_message("cannot parse empty patch input"), ErrorCategory::InvalidParameters);
888        assert_eq!(classify_error_message("patch does not contain any operations"), ErrorCategory::InvalidParameters);
889        assert_eq!(
890            classify_error_message("semantic patch anchor 'fn main' for 'src/main.rs' could not be resolved"),
891            ErrorCategory::InvalidParameters
892        );
893    }
894
895    #[test]
896    fn permission_denied() {
897        assert_eq!(classify_error_message("permission denied: /etc/shadow"), ErrorCategory::PermissionDenied);
898    }
899
900    #[test]
901    fn cancelled_operations() {
902        assert_eq!(classify_error_message("operation cancelled by user"), ErrorCategory::Cancelled);
903    }
904
905    #[test]
906    fn planning_policy_violation() {
907        assert_eq!(classify_error_message("not allowed in planning workflow"), ErrorCategory::PolicyViolation);
908    }
909
910    #[test]
911    fn sandbox_failure() {
912        assert_eq!(classify_error_message("sandbox denied this operation"), ErrorCategory::SandboxFailure);
913    }
914
915    #[test]
916    fn unknown_error_is_execution_error() {
917        assert_eq!(classify_error_message("something went wrong"), ErrorCategory::ExecutionError);
918    }
919
920    #[test]
921    fn invalid_parameters() {
922        assert_eq!(classify_error_message("invalid argument: missing path field"), ErrorCategory::InvalidParameters);
923        assert_eq!(
924            classify_error_message("Failed to parse arguments for read_file handler: invalid type: boolean `false`"),
925            ErrorCategory::InvalidParameters
926        );
927        assert_eq!(
928            classify_error_message("at least one item is required for 'create'"),
929            ErrorCategory::InvalidParameters
930        );
931        assert_eq!(
932            classify_error_message("structural pattern preflight failed: pattern is not parseable as Rust syntax"),
933            ErrorCategory::InvalidParameters
934        );
935    }
936
937    // --- classify_anyhow_error: type-first recovery, context-aware fallback ---
938
939    #[test]
940    fn classify_anyhow_prefers_typed_llm_error_under_context() {
941        // A typed `LLMError` must win over an uninformative outer context:
942        // the outer message alone would classify as `ExecutionError`, but the
943        // error's type is the source of truth for its recovery policy.
944        let rate = anyhow::Error::new(crate::llm::LLMError::RateLimit { metadata: None }).context("compaction failed");
945        assert_eq!(classify_anyhow_error(&rate), ErrorCategory::RateLimit);
946
947        let auth = anyhow::Error::new(crate::llm::LLMError::Authentication {
948            message: "bad token".to_string(),
949            metadata: None,
950        })
951        .context("request failed");
952        assert_eq!(classify_anyhow_error(&auth), ErrorCategory::Authentication);
953    }
954
955    #[test]
956    fn classify_anyhow_reads_deeper_cause_chain() {
957        // When the outer context is uninformative and no typed error survives,
958        // the underlying cause must still be classified (`{err:#}`-style).
959        let inner = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "connection reset by peer");
960        let err = anyhow::Error::new(inner).context("failed to fetch resource");
961        assert_eq!(classify_anyhow_error(&err), ErrorCategory::Network);
962    }
963
964    #[test]
965    fn classify_anyhow_keeps_outermost_context_priority() {
966        // A deliberate, specific `.context(...)` still wins over a generic root.
967        let inner = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file");
968        let err = anyhow::Error::new(inner).context("tool permission denied by policy");
969        assert_eq!(classify_anyhow_error(&err), ErrorCategory::PolicyViolation);
970    }
971
972    #[test]
973    fn classify_anyhow_unknown_stays_execution_error() {
974        let err = anyhow::anyhow!("something went wrong");
975        assert_eq!(classify_anyhow_error(&err), ErrorCategory::ExecutionError);
976    }
977
978    // --- Retryability tests ---
979
980    #[test]
981    fn retryable_categories() {
982        assert!(ErrorCategory::Network.is_retryable());
983        assert!(ErrorCategory::Timeout.is_retryable());
984        assert!(ErrorCategory::RateLimit.is_retryable());
985        assert!(ErrorCategory::ServiceUnavailable.is_retryable());
986        assert!(ErrorCategory::CircuitOpen.is_retryable());
987    }
988
989    #[test]
990    fn non_retryable_categories() {
991        assert!(!ErrorCategory::Authentication.is_retryable());
992        assert!(!ErrorCategory::InvalidParameters.is_retryable());
993        assert!(!ErrorCategory::PolicyViolation.is_retryable());
994        assert!(!ErrorCategory::ResourceExhausted.is_retryable());
995        assert!(!ErrorCategory::Cancelled.is_retryable());
996    }
997
998    #[test]
999    fn permanent_error_detection() {
1000        assert!(ErrorCategory::Authentication.is_permanent());
1001        assert!(ErrorCategory::PolicyViolation.is_permanent());
1002        assert!(!ErrorCategory::Network.is_permanent());
1003        assert!(!ErrorCategory::Timeout.is_permanent());
1004    }
1005
1006    #[test]
1007    fn llm_mistake_detection() {
1008        assert!(ErrorCategory::InvalidParameters.is_llm_mistake());
1009        assert!(!ErrorCategory::Network.is_llm_mistake());
1010        assert!(!ErrorCategory::Timeout.is_llm_mistake());
1011    }
1012
1013    // --- LLM error conversion ---
1014
1015    #[test]
1016    fn llm_error_authentication_converts() {
1017        let err = crate::llm::LLMError::Authentication { message: "bad key".to_string(), metadata: None };
1018        assert_eq!(ErrorCategory::from(&err), ErrorCategory::Authentication);
1019    }
1020
1021    #[test]
1022    fn llm_error_rate_limit_converts() {
1023        let err = crate::llm::LLMError::RateLimit { metadata: None };
1024        assert_eq!(ErrorCategory::from(&err), ErrorCategory::RateLimit);
1025    }
1026
1027    #[test]
1028    fn llm_error_quota_exhaustion_converts() {
1029        let err = crate::llm::LLMError::RateLimit {
1030            metadata: Some(crate::llm::LLMErrorMetadata::new(
1031                "openai",
1032                Some(429),
1033                Some("insufficient_quota".to_string()),
1034                None,
1035                None,
1036                None,
1037                Some("quota exceeded".to_string()),
1038            )),
1039        };
1040
1041        assert_eq!(ErrorCategory::from(&err), ErrorCategory::ResourceExhausted);
1042    }
1043
1044    #[test]
1045    fn llm_error_network_converts() {
1046        let err = crate::llm::LLMError::Network {
1047            message: "connection refused".to_string(),
1048            metadata: None,
1049        };
1050        assert_eq!(ErrorCategory::from(&err), ErrorCategory::Network);
1051    }
1052
1053    #[test]
1054    fn llm_error_provider_with_status_code() {
1055        use crate::llm::LLMErrorMetadata;
1056        let err = crate::llm::LLMError::Provider {
1057            message: "error".to_string(),
1058            metadata: Some(LLMErrorMetadata::new("openai", Some(503), None, None, None, None, None)),
1059        };
1060        assert_eq!(ErrorCategory::from(&err), ErrorCategory::ServiceUnavailable);
1061    }
1062
1063    #[test]
1064    fn minimax_invalid_response_is_service_unavailable() {
1065        assert_eq!(
1066            classify_error_message("Invalid response from MiniMax: missing choices"),
1067            ErrorCategory::ServiceUnavailable
1068        );
1069        assert_eq!(
1070            classify_error_message("Invalid response format: missing message"),
1071            ErrorCategory::ServiceUnavailable
1072        );
1073    }
1074
1075    // --- is_retryable_llm_error_message ---
1076
1077    #[test]
1078    fn retryable_llm_messages() {
1079        assert!(is_retryable_llm_error_message("429 too many requests"));
1080        assert!(is_retryable_llm_error_message("500 internal server error"));
1081        assert!(is_retryable_llm_error_message("connection timeout"));
1082        assert!(is_retryable_llm_error_message("network error"));
1083    }
1084
1085    #[test]
1086    fn non_retryable_llm_messages() {
1087        assert!(!is_retryable_llm_error_message("invalid api key"));
1088        assert!(!is_retryable_llm_error_message("weekly usage limit reached"));
1089        assert!(!is_retryable_llm_error_message("permission denied"));
1090    }
1091
1092    #[test]
1093    fn context_capacity_markers_are_specific() {
1094        assert!(is_context_capacity_message("invalid request: maximum context length is 114688 tokens"));
1095        assert!(is_context_capacity_message("input token count exceeds the maximum number of tokens allowed"));
1096        assert!(!is_context_capacity_message("invalid request: context field is missing"));
1097    }
1098
1099    #[test]
1100    fn context_capacity_markers_cover_gateway_variants() {
1101        // Near-budget follow-up failures from merge-gateway / Anthropic /
1102        // OpenAI routes must enter the compacting recovery path instead of
1103        // falling through to generic `ExecutionError`.
1104        for msg in [
1105            "input tokens exceed context limit (956758 > 1000000)",
1106            "prompt tokens exceed the model context window",
1107            "context limit exceeded: too many input tokens",
1108            "request exceeds context size for anthropic/claude-sonnet-5",
1109            "token limit exceeded for prompt",
1110            "prompt too large for context window",
1111        ] {
1112            assert!(is_context_capacity_message(msg), "{msg} should be a capacity signal");
1113        }
1114        // Negative guard: ordinary prose must stay out of recovery (the
1115        // field-missing case is covered in `context_capacity_markers_are_specific`).
1116        assert!(!is_context_capacity_message("upstream stream file ready"));
1117        // "context limited" contains "context limit" as a substring; it must
1118        // not enter capacity recovery.
1119        assert!(!is_context_capacity_message("context limited to 5 tools"));
1120    }
1121
1122    // --- Recovery suggestions ---
1123
1124    #[test]
1125    fn recovery_suggestions_non_empty() {
1126        for cat in [
1127            ErrorCategory::Network,
1128            ErrorCategory::Timeout,
1129            ErrorCategory::RateLimit,
1130            ErrorCategory::Authentication,
1131            ErrorCategory::InvalidParameters,
1132            ErrorCategory::ToolNotFound,
1133            ErrorCategory::ResourceNotFound,
1134            ErrorCategory::PermissionDenied,
1135            ErrorCategory::PolicyViolation,
1136            ErrorCategory::ExecutionError,
1137        ] {
1138            assert!(!cat.recovery_suggestions().is_empty(), "Missing recovery suggestions for {cat:?}");
1139        }
1140    }
1141
1142    // --- User label ---
1143
1144    #[test]
1145    fn user_labels_are_non_empty() {
1146        assert!(!ErrorCategory::Network.user_label().is_empty());
1147        assert!(!ErrorCategory::ExecutionError.user_label().is_empty());
1148    }
1149
1150    // --- auth_recovery_guidance ---
1151
1152    #[test]
1153    fn auth_recovery_guidance_no_credential_mentions_secret_add() {
1154        let guidance = ErrorCategory::Authentication.auth_recovery_guidance("StepFun", "stepfun", false, false);
1155        assert_eq!(guidance.len(), 1);
1156        assert_eq!(
1157            guidance[0],
1158            "Authentication failed for StepFun. Run /secret add stepfun to store your API key in secure storage (OS keyring or encrypted file)."
1159        );
1160    }
1161
1162    #[test]
1163    fn auth_recovery_guidance_credential_stored_mentions_overwrite() {
1164        let guidance = ErrorCategory::Authentication.auth_recovery_guidance("StepFun", "stepfun", false, true);
1165        assert_eq!(guidance.len(), 1);
1166        assert_eq!(
1167            guidance[0],
1168            "Authentication failed for StepFun. The stored API key was rejected — run /secret add stepfun to replace it with a valid key."
1169        );
1170    }
1171
1172    #[test]
1173    fn auth_recovery_guidance_managed_auth_provider_mentions_login() {
1174        let guidance = ErrorCategory::Authentication.auth_recovery_guidance("GitHub Copilot", "copilot", true, false);
1175        assert_eq!(guidance.len(), 1);
1176        assert_eq!(guidance[0], "Authentication failed for GitHub Copilot. Run /login copilot to re-authenticate.");
1177    }
1178
1179    #[test]
1180    fn auth_recovery_guidance_non_auth_category_returns_empty() {
1181        assert!(
1182            ErrorCategory::Network
1183                .auth_recovery_guidance("OpenAI", "openai", false, false)
1184                .is_empty()
1185        );
1186        assert!(
1187            ErrorCategory::Timeout
1188                .auth_recovery_guidance("OpenAI", "openai", false, false)
1189                .is_empty()
1190        );
1191    }
1192
1193    // --- Display ---
1194
1195    #[test]
1196    fn display_matches_user_label() {
1197        assert_eq!(format!("{}", ErrorCategory::RateLimit), ErrorCategory::RateLimit.user_label());
1198    }
1199}