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#[must_use]
344pub fn classify_anyhow_error(err: &anyhow::Error) -> ErrorCategory {
345    let msg = err.to_string().to_ascii_lowercase();
346    classify_error_message(&msg)
347}
348
349/// Return whether an error explicitly reports that the provider rejected the
350/// request because its input context is too large.
351///
352/// Context-capacity failures are intentionally kept separate from
353/// [`ErrorCategory::InvalidParameters`]: they are not model argument mistakes
354/// and a bounded history compaction can make the same request valid. The
355/// marker set is deliberately specific so ordinary validation messages that
356/// merely mention a context field do not enter recovery.
357#[must_use]
358pub fn is_context_capacity_error(err: &anyhow::Error) -> bool {
359    err.chain().any(|cause| is_context_capacity_message(&cause.to_string()))
360}
361
362/// Return whether a provider error message identifies an input-context limit.
363#[must_use]
364pub fn is_context_capacity_message(message: &str) -> bool {
365    let message = message.to_ascii_lowercase();
366    contains_any(
367        &message,
368        &[
369            "context_length_exceeded",
370            "context length exceeded",
371            "maximum context length",
372            "maximum context window",
373            "context window exceeded",
374            "context window is too small",
375            "exceeds the model's maximum context",
376            "exceeds model context",
377            "exceeds the maximum context",
378            "prompt is too long",
379            "input is too long",
380            "input token count exceeds",
381            "exceeds the maximum number of tokens",
382            "maximum input tokens",
383            "too many tokens in the prompt",
384            "request exceeds the context",
385            // Merge-gateway / Anthropic / OpenAI variants observed on
386            // near-budget follow-ups (e.g. ~956k prompt on a 1M budget) that
387            // previously fell through to generic `ExecutionError` and missed
388            // the compacting tool-enabled recovery path.
389            "context limit exceeded",
390            "context size exceeded",
391            "exceeds context",
392            "input tokens exceed",
393            "prompt tokens exceed",
394            "prompt token count exceeds",
395            "token limit exceeded",
396            "input too large",
397            "prompt too large",
398        ],
399    )
400}
401
402/// Classify an error message string into an `ErrorCategory`.
403///
404/// Marker groups are checked in priority order to handle overlapping patterns
405/// (e.g., "tool permission denied by policy" → `PolicyViolation`, not `PermissionDenied`).
406#[inline]
407#[must_use]
408pub fn classify_error_message(msg: &str) -> ErrorCategory {
409    let msg = if msg.as_bytes().iter().any(|b| b.is_ascii_uppercase()) {
410        Cow::Owned(msg.to_ascii_lowercase())
411    } else {
412        Cow::Borrowed(msg)
413    };
414
415    // --- Priority 1: Policy violations (before permission checks) ---
416    if contains_any(
417        &msg,
418        &[
419            "policy violation",
420            "denied by policy",
421            "tool permission denied",
422            "safety validation failed",
423            "not allowed in planning workflow",
424            "only available when planning workflow is active",
425            "workspace boundary",
426            "blocked by policy",
427        ],
428    ) {
429        return ErrorCategory::PolicyViolation;
430    }
431
432    // --- Priority 2: Planning workflow violations ---
433    if contains_any(
434        &msg,
435        &[
436            "planning workflow",
437            "read-only permissions",
438            concat!("read-only ", "mode"),
439            "planning_policy_violation",
440        ],
441    ) {
442        return ErrorCategory::PlanningPolicyViolation;
443    }
444
445    // --- Priority 3: Authentication / Authorization ---
446    if contains_any(
447        &msg,
448        &[
449            "invalid api key",
450            "authentication failed",
451            "unauthorized",
452            "401",
453            "invalid credentials",
454        ],
455    ) {
456        return ErrorCategory::Authentication;
457    }
458
459    // --- Priority 4: Non-retryable resource exhaustion (billing, quotas) ---
460    if contains_any(
461        &msg,
462        &[
463            "weekly usage limit",
464            "daily usage limit",
465            "monthly spending limit",
466            "insufficient credits",
467            "quota exceeded",
468            "billing",
469            "payment required",
470        ],
471    ) {
472        return ErrorCategory::ResourceExhausted;
473    }
474
475    // --- Priority 5: Invalid parameters ---
476    if contains_any(
477        &msg,
478        &[
479            "invalid argument",
480            "invalid parameters",
481            "invalid type",
482            "malformed",
483            "failed to parse arguments",
484            "failed to parse argument",
485            "missing required",
486            "at least one item is required",
487            "is required for",
488            "schema validation",
489            "argument validation failed",
490            "unknown field",
491            "unknown variant",
492            "expected struct",
493            "expected enum",
494            "type mismatch",
495            "must be an absolute path",
496            "not parseable",
497            "parseable as",
498            // Patch format errors — these are LLM argument mistakes (the model
499            // sent a malformed patch), not execution failures. Classifying them
500            // as InvalidParameters ensures they don't trip the circuit breaker
501            // (is_llm_mistake() == true) and get parameter-focused recovery
502            // suggestions. See checkpoint turn_615 for the failure this
503            // prevents: a unified-diff patch was classified as ExecutionError
504            // and got generic "check tool documentation" suggestions.
505            "invalid patch format",
506            "invalid patch hunk",
507            "invalid patch operation",
508            "cannot parse empty patch",
509            "patch does not contain",
510            "semantic patch anchor",
511        ],
512    ) {
513        return ErrorCategory::InvalidParameters;
514    }
515
516    // --- Priority 6: Tool not found ---
517    if contains_any(&msg, &["tool not found", "unknown tool", "unsupported tool", "no such tool"]) {
518        return ErrorCategory::ToolNotFound;
519    }
520
521    // --- Priority 7: Resource not found ---
522    if contains_any(
523        &msg,
524        &[
525            "no such file",
526            "no such directory",
527            "file not found",
528            "directory not found",
529            "resource not found",
530            "path not found",
531            "does not exist",
532            "enoent",
533        ],
534    ) {
535        return ErrorCategory::ResourceNotFound;
536    }
537
538    // --- Priority 8: Permission denied (OS-level) ---
539    if contains_any(
540        &msg,
541        &[
542            "permission denied",
543            "access denied",
544            "operation not permitted",
545            "eacces",
546            "eperm",
547            "forbidden",
548            "403",
549        ],
550    ) {
551        return ErrorCategory::PermissionDenied;
552    }
553
554    // --- Priority 9: Cancellation ---
555    if contains_any(&msg, &["cancelled", "interrupted", "canceled"]) {
556        return ErrorCategory::Cancelled;
557    }
558
559    // --- Priority 10: Circuit breaker ---
560    if contains_any(&msg, &["circuit breaker", "circuit open"]) {
561        return ErrorCategory::CircuitOpen;
562    }
563
564    // --- Priority 11: Sandbox ---
565    if contains_any(&msg, &["sandbox denied", "sandbox failure"]) {
566        return ErrorCategory::SandboxFailure;
567    }
568
569    // --- Priority 12: Rate limiting (before general network) ---
570    if contains_any(&msg, &["rate limit", "too many requests", "429", "throttl"]) {
571        return ErrorCategory::RateLimit;
572    }
573
574    // --- Priority 13: Timeout ---
575    if contains_any(&msg, &["timeout", "timed out", "deadline exceeded"]) {
576        return ErrorCategory::Timeout;
577    }
578
579    // --- Priority 14: Provider transient response-shape failures ---
580    if contains_any(
581        &msg,
582        &[
583            "invalid response format: missing choices",
584            "invalid response format: missing message",
585            "missing choices in response",
586            "missing message in choice",
587            "no choices in response",
588            "invalid response from ",
589            "empty response body",
590            "response did not contain",
591            "unexpected response format",
592            "failed to parse response",
593            // Post-tool follow-up streaming failures surface here when the
594            // provider drops the SSE/event stream mid-response. Without these
595            // markers the follow-up is misclassified as generic
596            // `ExecutionError` (non-retryable) and the turn ends instead of
597            // scheduling the bounded tool-free retry.
598            "stream disconnected",
599            "stream closed unexpectedly",
600            "incomplete stream",
601            "unexpected end of stream",
602            "truncated response",
603            "failed to decode stream",
604            "stream terminated",
605        ],
606    ) {
607        return ErrorCategory::ServiceUnavailable;
608    }
609
610    // --- Priority 15: Service unavailable (HTTP 5xx and related) ---
611    if contains_any(
612        &msg,
613        &[
614            "service unavailable",
615            "temporarily unavailable",
616            "internal server error",
617            "bad gateway",
618            "gateway timeout",
619            "overloaded",
620            "500",
621            "502",
622            "503",
623            "504",
624        ],
625    ) {
626        return ErrorCategory::ServiceUnavailable;
627    }
628
629    // --- Priority 16: Network (connectivity, DNS, transport) ---
630    if contains_any(
631        &msg,
632        &[
633            "network",
634            "connection reset",
635            "connection refused",
636            "broken pipe",
637            "dns",
638            "name resolution",
639            "try again",
640            "retry later",
641            "upstream connect error",
642            "tls handshake",
643            "socket hang up",
644            "econnreset",
645            "etimedout",
646            // reqwest surfaces truncated/aborted response streams as a body
647            // decode failure; Ollama (cloud) emits this on transient drops.
648            // It is a transport error, not a payload problem — retryable.
649            "error decoding response body",
650            // Transport-level stream teardown during a follow-up request.
651            // Distinct from response-shape markers above: the connection
652            // itself failed, not the payload. `stream error` stays specific
653            // (`received`) so `downstream error` prose does not match.
654            "connection closed before",
655            "stream reset",
656            "stream error received",
657        ],
658    ) {
659        return ErrorCategory::Network;
660    }
661
662    // --- Priority 17: Resource exhausted (memory, disk) ---
663    if contains_any(&msg, &["out of memory", "disk full", "no space left"]) {
664        return ErrorCategory::ResourceExhausted;
665    }
666
667    // --- Fallback ---
668    ErrorCategory::ExecutionError
669}
670
671/// Check if an LLM error message is retryable (used by the LLM request retry loop).
672///
673/// This is a focused classifier for LLM provider errors, combining
674/// non-retryable and retryable marker checks for the request retry path.
675#[inline]
676#[must_use]
677pub fn is_retryable_llm_error_message(msg: &str) -> bool {
678    let category = classify_error_message(msg);
679    category.is_retryable()
680}
681
682#[inline]
683fn contains_any(message: &str, markers: &[&str]) -> bool {
684    markers.iter().any(|marker| message.contains(marker))
685}
686
687// ---------------------------------------------------------------------------
688// Typed conversions from known error types
689// ---------------------------------------------------------------------------
690
691impl From<&crate::llm::LLMError> for ErrorCategory {
692    fn from(err: &crate::llm::LLMError) -> Self {
693        match err {
694            crate::llm::LLMError::Authentication { .. } => ErrorCategory::Authentication,
695            crate::llm::LLMError::RateLimit { metadata } => {
696                classify_llm_metadata(metadata.as_deref(), ErrorCategory::RateLimit)
697            }
698            crate::llm::LLMError::InvalidRequest { .. } => ErrorCategory::InvalidParameters,
699            crate::llm::LLMError::Network { .. } => ErrorCategory::Network,
700            crate::llm::LLMError::Provider { message, metadata } => {
701                let metadata_category = classify_llm_metadata(metadata.as_deref(), ErrorCategory::ExecutionError);
702                if metadata_category != ErrorCategory::ExecutionError {
703                    return metadata_category;
704                }
705
706                // Check metadata status code first for precise classification
707                if let Some(meta) = metadata
708                    && let Some(status) = meta.status
709                {
710                    return match status {
711                        401 => ErrorCategory::Authentication,
712                        403 => ErrorCategory::PermissionDenied,
713                        404 => ErrorCategory::ResourceNotFound,
714                        429 => ErrorCategory::RateLimit,
715                        400 => ErrorCategory::InvalidParameters,
716                        500 | 502 | 503 | 504 => ErrorCategory::ServiceUnavailable,
717                        408 => ErrorCategory::Timeout,
718                        _ => classify_error_message(message),
719                    };
720                }
721                // Fall back to message-based classification
722                classify_error_message(message)
723            }
724        }
725    }
726}
727
728fn classify_llm_metadata(metadata: Option<&crate::llm::LLMErrorMetadata>, fallback: ErrorCategory) -> ErrorCategory {
729    let Some(metadata) = metadata else {
730        return fallback;
731    };
732
733    let mut hint = String::new();
734    if let Some(code) = &metadata.code {
735        hint.push_str(code);
736        hint.push(' ');
737    }
738    if let Some(message) = &metadata.message {
739        hint.push_str(message);
740        hint.push(' ');
741    }
742    if let Some(status) = metadata.status {
743        let _ = write!(&mut hint, "{status}");
744    }
745
746    let classified = classify_error_message(&hint);
747    if classified == ErrorCategory::ExecutionError {
748        fallback
749    } else {
750        classified
751    }
752}
753
754#[cfg(test)]
755mod tests {
756    use super::*;
757
758    // --- classify_error_message tests ---
759
760    #[test]
761    fn policy_violation_takes_priority_over_permission() {
762        assert_eq!(classify_error_message("tool permission denied by policy"), ErrorCategory::PolicyViolation);
763    }
764
765    #[test]
766    fn rate_limit_classified_correctly() {
767        assert_eq!(classify_error_message("provider returned 429 Too Many Requests"), ErrorCategory::RateLimit);
768        assert_eq!(classify_error_message("rate limit exceeded"), ErrorCategory::RateLimit);
769    }
770
771    #[test]
772    fn service_unavailable_is_classified() {
773        assert_eq!(classify_error_message("503 service unavailable"), ErrorCategory::ServiceUnavailable);
774    }
775
776    #[test]
777    fn follow_up_stream_failures_are_retryable() {
778        // Post-tool follow-up streaming drops must not fall through to
779        // generic `ExecutionError`: both shapes are retryable.
780        for msg in [
781            "follow-up failed: stream disconnected mid-response",
782            "sse stream terminated before completion",
783            "incomplete stream while reading follow-up",
784            "connection closed before response completed",
785            "stream reset by peer during follow-up",
786            "h2 stream error received: internal error",
787        ] {
788            let category = classify_error_message(msg);
789            assert!(category.is_retryable(), "{msg} -> {category:?} should be retryable");
790        }
791        // Asymmetric boundary: unrelated `stream` prose without a failure
792        // marker stays non-retryable, and `downstream error` must not match
793        // the h2 `stream error received` marker.
794        assert_eq!(classify_error_message("upstream stream file ready"), ErrorCategory::ExecutionError);
795        assert_eq!(classify_error_message("downstream error: 400 bad request"), ErrorCategory::ExecutionError);
796        assert_eq!(classify_error_message("something went wrong"), ErrorCategory::ExecutionError);
797    }
798
799    #[test]
800    fn authentication_errors() {
801        assert_eq!(classify_error_message("invalid api key provided"), ErrorCategory::Authentication);
802        assert_eq!(classify_error_message("401 unauthorized"), ErrorCategory::Authentication);
803    }
804
805    #[test]
806    fn billing_errors_are_resource_exhausted() {
807        assert_eq!(
808            classify_error_message("you have reached your weekly usage limit"),
809            ErrorCategory::ResourceExhausted
810        );
811        assert_eq!(classify_error_message("quota exceeded for this model"), ErrorCategory::ResourceExhausted);
812    }
813
814    #[test]
815    fn timeout_errors() {
816        assert_eq!(classify_error_message("connection timeout"), ErrorCategory::Timeout);
817        assert_eq!(classify_error_message("request timed out after 30s"), ErrorCategory::Timeout);
818    }
819
820    #[test]
821    fn network_errors() {
822        assert_eq!(classify_error_message("connection reset by peer"), ErrorCategory::Network);
823        assert_eq!(classify_error_message("dns name resolution failed"), ErrorCategory::Network);
824    }
825
826    #[test]
827    fn tool_not_found() {
828        assert_eq!(classify_error_message("unknown tool: ask_questions"), ErrorCategory::ToolNotFound);
829    }
830
831    #[test]
832    fn resource_not_found() {
833        assert_eq!(classify_error_message("no such file or directory: /tmp/missing"), ErrorCategory::ResourceNotFound);
834        assert_eq!(
835            classify_error_message("Path 'crates/codegen/vtcode-core/src/agent' does not exist"),
836            ErrorCategory::ResourceNotFound
837        );
838    }
839
840    #[test]
841    fn patch_format_errors_are_invalid_parameters() {
842        // Patch format errors are LLM argument mistakes, not execution
843        // failures. They must classify as InvalidParameters (is_llm_mistake
844        // == true, no circuit breaker trip) so the model gets parameter-
845        // focused recovery suggestions instead of generic "check tool docs".
846        assert_eq!(
847            classify_error_message("invalid patch format: missing '*** Begin Patch' marker"),
848            ErrorCategory::InvalidParameters
849        );
850        assert_eq!(
851            classify_error_message("invalid patch format: input looks like a standard unified diff (---/+++ format)"),
852            ErrorCategory::InvalidParameters
853        );
854        assert_eq!(
855            classify_error_message("invalid patch hunk on line 5: unexpected end of input"),
856            ErrorCategory::InvalidParameters
857        );
858        assert_eq!(classify_error_message("cannot parse empty patch input"), ErrorCategory::InvalidParameters);
859        assert_eq!(classify_error_message("patch does not contain any operations"), ErrorCategory::InvalidParameters);
860        assert_eq!(
861            classify_error_message("semantic patch anchor 'fn main' for 'src/main.rs' could not be resolved"),
862            ErrorCategory::InvalidParameters
863        );
864    }
865
866    #[test]
867    fn permission_denied() {
868        assert_eq!(classify_error_message("permission denied: /etc/shadow"), ErrorCategory::PermissionDenied);
869    }
870
871    #[test]
872    fn cancelled_operations() {
873        assert_eq!(classify_error_message("operation cancelled by user"), ErrorCategory::Cancelled);
874    }
875
876    #[test]
877    fn planning_policy_violation() {
878        assert_eq!(classify_error_message("not allowed in planning workflow"), ErrorCategory::PolicyViolation);
879    }
880
881    #[test]
882    fn sandbox_failure() {
883        assert_eq!(classify_error_message("sandbox denied this operation"), ErrorCategory::SandboxFailure);
884    }
885
886    #[test]
887    fn unknown_error_is_execution_error() {
888        assert_eq!(classify_error_message("something went wrong"), ErrorCategory::ExecutionError);
889    }
890
891    #[test]
892    fn invalid_parameters() {
893        assert_eq!(classify_error_message("invalid argument: missing path field"), ErrorCategory::InvalidParameters);
894        assert_eq!(
895            classify_error_message("Failed to parse arguments for read_file handler: invalid type: boolean `false`"),
896            ErrorCategory::InvalidParameters
897        );
898        assert_eq!(
899            classify_error_message("at least one item is required for 'create'"),
900            ErrorCategory::InvalidParameters
901        );
902        assert_eq!(
903            classify_error_message("structural pattern preflight failed: pattern is not parseable as Rust syntax"),
904            ErrorCategory::InvalidParameters
905        );
906    }
907
908    // --- Retryability tests ---
909
910    #[test]
911    fn retryable_categories() {
912        assert!(ErrorCategory::Network.is_retryable());
913        assert!(ErrorCategory::Timeout.is_retryable());
914        assert!(ErrorCategory::RateLimit.is_retryable());
915        assert!(ErrorCategory::ServiceUnavailable.is_retryable());
916        assert!(ErrorCategory::CircuitOpen.is_retryable());
917    }
918
919    #[test]
920    fn non_retryable_categories() {
921        assert!(!ErrorCategory::Authentication.is_retryable());
922        assert!(!ErrorCategory::InvalidParameters.is_retryable());
923        assert!(!ErrorCategory::PolicyViolation.is_retryable());
924        assert!(!ErrorCategory::ResourceExhausted.is_retryable());
925        assert!(!ErrorCategory::Cancelled.is_retryable());
926    }
927
928    #[test]
929    fn permanent_error_detection() {
930        assert!(ErrorCategory::Authentication.is_permanent());
931        assert!(ErrorCategory::PolicyViolation.is_permanent());
932        assert!(!ErrorCategory::Network.is_permanent());
933        assert!(!ErrorCategory::Timeout.is_permanent());
934    }
935
936    #[test]
937    fn llm_mistake_detection() {
938        assert!(ErrorCategory::InvalidParameters.is_llm_mistake());
939        assert!(!ErrorCategory::Network.is_llm_mistake());
940        assert!(!ErrorCategory::Timeout.is_llm_mistake());
941    }
942
943    // --- LLM error conversion ---
944
945    #[test]
946    fn llm_error_authentication_converts() {
947        let err = crate::llm::LLMError::Authentication { message: "bad key".to_string(), metadata: None };
948        assert_eq!(ErrorCategory::from(&err), ErrorCategory::Authentication);
949    }
950
951    #[test]
952    fn llm_error_rate_limit_converts() {
953        let err = crate::llm::LLMError::RateLimit { metadata: None };
954        assert_eq!(ErrorCategory::from(&err), ErrorCategory::RateLimit);
955    }
956
957    #[test]
958    fn llm_error_quota_exhaustion_converts() {
959        let err = crate::llm::LLMError::RateLimit {
960            metadata: Some(crate::llm::LLMErrorMetadata::new(
961                "openai",
962                Some(429),
963                Some("insufficient_quota".to_string()),
964                None,
965                None,
966                None,
967                Some("quota exceeded".to_string()),
968            )),
969        };
970
971        assert_eq!(ErrorCategory::from(&err), ErrorCategory::ResourceExhausted);
972    }
973
974    #[test]
975    fn llm_error_network_converts() {
976        let err = crate::llm::LLMError::Network {
977            message: "connection refused".to_string(),
978            metadata: None,
979        };
980        assert_eq!(ErrorCategory::from(&err), ErrorCategory::Network);
981    }
982
983    #[test]
984    fn llm_error_provider_with_status_code() {
985        use crate::llm::LLMErrorMetadata;
986        let err = crate::llm::LLMError::Provider {
987            message: "error".to_string(),
988            metadata: Some(LLMErrorMetadata::new("openai", Some(503), None, None, None, None, None)),
989        };
990        assert_eq!(ErrorCategory::from(&err), ErrorCategory::ServiceUnavailable);
991    }
992
993    #[test]
994    fn minimax_invalid_response_is_service_unavailable() {
995        assert_eq!(
996            classify_error_message("Invalid response from MiniMax: missing choices"),
997            ErrorCategory::ServiceUnavailable
998        );
999        assert_eq!(
1000            classify_error_message("Invalid response format: missing message"),
1001            ErrorCategory::ServiceUnavailable
1002        );
1003    }
1004
1005    // --- is_retryable_llm_error_message ---
1006
1007    #[test]
1008    fn retryable_llm_messages() {
1009        assert!(is_retryable_llm_error_message("429 too many requests"));
1010        assert!(is_retryable_llm_error_message("500 internal server error"));
1011        assert!(is_retryable_llm_error_message("connection timeout"));
1012        assert!(is_retryable_llm_error_message("network error"));
1013    }
1014
1015    #[test]
1016    fn non_retryable_llm_messages() {
1017        assert!(!is_retryable_llm_error_message("invalid api key"));
1018        assert!(!is_retryable_llm_error_message("weekly usage limit reached"));
1019        assert!(!is_retryable_llm_error_message("permission denied"));
1020    }
1021
1022    #[test]
1023    fn context_capacity_markers_are_specific() {
1024        assert!(is_context_capacity_message("invalid request: maximum context length is 114688 tokens"));
1025        assert!(is_context_capacity_message("input token count exceeds the maximum number of tokens allowed"));
1026        assert!(!is_context_capacity_message("invalid request: context field is missing"));
1027    }
1028
1029    #[test]
1030    fn context_capacity_markers_cover_gateway_variants() {
1031        // Near-budget follow-up failures from merge-gateway / Anthropic /
1032        // OpenAI routes must enter the compacting recovery path instead of
1033        // falling through to generic `ExecutionError`.
1034        for msg in [
1035            "input tokens exceed context limit (956758 > 1000000)",
1036            "prompt tokens exceed the model context window",
1037            "context limit exceeded: too many input tokens",
1038            "request exceeds context size for anthropic/claude-sonnet-5",
1039            "token limit exceeded for prompt",
1040            "prompt too large for context window",
1041        ] {
1042            assert!(is_context_capacity_message(msg), "{msg} should be a capacity signal");
1043        }
1044        // Negative guard: ordinary prose must stay out of recovery (the
1045        // field-missing case is covered in `context_capacity_markers_are_specific`).
1046        assert!(!is_context_capacity_message("upstream stream file ready"));
1047        // "context limited" contains "context limit" as a substring; it must
1048        // not enter capacity recovery.
1049        assert!(!is_context_capacity_message("context limited to 5 tools"));
1050    }
1051
1052    // --- Recovery suggestions ---
1053
1054    #[test]
1055    fn recovery_suggestions_non_empty() {
1056        for cat in [
1057            ErrorCategory::Network,
1058            ErrorCategory::Timeout,
1059            ErrorCategory::RateLimit,
1060            ErrorCategory::Authentication,
1061            ErrorCategory::InvalidParameters,
1062            ErrorCategory::ToolNotFound,
1063            ErrorCategory::ResourceNotFound,
1064            ErrorCategory::PermissionDenied,
1065            ErrorCategory::PolicyViolation,
1066            ErrorCategory::ExecutionError,
1067        ] {
1068            assert!(!cat.recovery_suggestions().is_empty(), "Missing recovery suggestions for {cat:?}");
1069        }
1070    }
1071
1072    // --- User label ---
1073
1074    #[test]
1075    fn user_labels_are_non_empty() {
1076        assert!(!ErrorCategory::Network.user_label().is_empty());
1077        assert!(!ErrorCategory::ExecutionError.user_label().is_empty());
1078    }
1079
1080    // --- auth_recovery_guidance ---
1081
1082    #[test]
1083    fn auth_recovery_guidance_no_credential_mentions_secret_add() {
1084        let guidance = ErrorCategory::Authentication.auth_recovery_guidance("StepFun", "stepfun", false, false);
1085        assert_eq!(guidance.len(), 1);
1086        assert_eq!(
1087            guidance[0],
1088            "Authentication failed for StepFun. Run /secret add stepfun to store your API key in secure storage (OS keyring or encrypted file)."
1089        );
1090    }
1091
1092    #[test]
1093    fn auth_recovery_guidance_credential_stored_mentions_overwrite() {
1094        let guidance = ErrorCategory::Authentication.auth_recovery_guidance("StepFun", "stepfun", false, true);
1095        assert_eq!(guidance.len(), 1);
1096        assert_eq!(
1097            guidance[0],
1098            "Authentication failed for StepFun. The stored API key was rejected — run /secret add stepfun to replace it with a valid key."
1099        );
1100    }
1101
1102    #[test]
1103    fn auth_recovery_guidance_managed_auth_provider_mentions_login() {
1104        let guidance = ErrorCategory::Authentication.auth_recovery_guidance("GitHub Copilot", "copilot", true, false);
1105        assert_eq!(guidance.len(), 1);
1106        assert_eq!(guidance[0], "Authentication failed for GitHub Copilot. Run /login copilot to re-authenticate.");
1107    }
1108
1109    #[test]
1110    fn auth_recovery_guidance_non_auth_category_returns_empty() {
1111        assert!(
1112            ErrorCategory::Network
1113                .auth_recovery_guidance("OpenAI", "openai", false, false)
1114                .is_empty()
1115        );
1116        assert!(
1117            ErrorCategory::Timeout
1118                .auth_recovery_guidance("OpenAI", "openai", false, false)
1119                .is_empty()
1120        );
1121    }
1122
1123    // --- Display ---
1124
1125    #[test]
1126    fn display_matches_user_label() {
1127        assert_eq!(format!("{}", ErrorCategory::RateLimit), ErrorCategory::RateLimit.user_label());
1128    }
1129}