Skip to main content

everruns_provider/
error.rs

1// Error types for the agent loop
2//
3// StoreResultExt: extension trait to replace repeated .map_err(|e| AgentLoopError::store(...))? patterns
4// json_val / from_json: helpers to replace repeated serde_json::to_value/from_value boilerplate
5
6use crate::typed_id::{AgentId, HarnessId, SessionId};
7use crate::user_facing_error::{
8    UserFacingError, UserFacingErrorContext, classify_runtime_error_message,
9    codes as user_facing_error_codes, is_provider_quota_message, is_usage_limit_message,
10};
11use serde::{Deserialize, Serialize, de::DeserializeOwned};
12use thiserror::Error;
13
14/// Result type alias for agent loop operations
15pub type Result<T> = std::result::Result<T, AgentLoopError>;
16
17/// Semantic classification of an LLM provider error, assigned by the driver
18/// at the provider boundary where the HTTP status and response body are still
19/// available. Downstream consumers prefer this over re-parsing error strings;
20/// `LlmErrorKind::Other` falls back to string classification
21/// (`classify_runtime_error_message`) so untyped errors keep working.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum LlmErrorKind {
25    /// Invalid or missing credentials, or access denied (401/403, bad API key).
26    Authentication,
27    /// Provider account is out of credits/quota (billing). Non-transient:
28    /// needs operator action, unlike a regular rate limit.
29    QuotaExhausted,
30    /// Transient rate limit (429).
31    RateLimited,
32    /// Provider outage or unreachable (5xx, 529, network failure).
33    Unavailable,
34    /// Provider rejected the request shape (4xx that is not auth/quota/429).
35    InvalidRequest,
36    /// Unclassified; downstream falls back to string classification.
37    Other,
38}
39
40impl LlmErrorKind {
41    /// Classify a provider's stable machine-readable error code.
42    pub fn from_provider_code(code: &str) -> Option<Self> {
43        let code = code.trim().to_ascii_lowercase();
44        match code.as_str() {
45            "insufficient_quota" | "billing_hard_limit_reached" | "credit_balance_too_low" => {
46                Some(Self::QuotaExhausted)
47            }
48            "authentication_error" | "invalid_api_key" | "permission_denied" => {
49                Some(Self::Authentication)
50            }
51            "rate_limit_exceeded" | "rate_limit_error" | "overloaded_error" => {
52                Some(Self::RateLimited)
53            }
54            "server_error"
55            | "internal_error"
56            | "processing_error"
57            | "service_unavailable"
58            | "timeout" => Some(Self::Unavailable),
59            "invalid_request_error" | "model_not_found" => Some(Self::InvalidRequest),
60            _ => None,
61        }
62    }
63
64    /// Classify a provider HTTP error from status code + response body.
65    ///
66    /// Quota/billing patterns are checked before the status code because
67    /// providers surface exhausted billing under different statuses
68    /// (OpenAI: 429 `insufficient_quota`, Anthropic: 400 "credit balance is
69    /// too low").
70    pub fn from_provider_status(status: u16, body: &str) -> Self {
71        if is_provider_quota_message(body) || is_usage_limit_message(body) {
72            return LlmErrorKind::QuotaExhausted;
73        }
74        match status {
75            401 | 403 => LlmErrorKind::Authentication,
76            429 => LlmErrorKind::RateLimited,
77            408 | 409 => LlmErrorKind::Unavailable,
78            501 => LlmErrorKind::Other,
79            500..=599 => LlmErrorKind::Unavailable,
80            400..=499 => LlmErrorKind::InvalidRequest,
81            _ => LlmErrorKind::Other,
82        }
83    }
84
85    /// Keyword-based classification for drivers without an HTTP status at the
86    /// error site (e.g. Bedrock SDK errors).
87    pub fn from_error_text(text: &str) -> Self {
88        if is_provider_quota_message(text) || is_usage_limit_message(text) {
89            return LlmErrorKind::QuotaExhausted;
90        }
91        let lower = text.to_ascii_lowercase();
92        if lower.contains("throttlingexception")
93            || lower.contains("toomanyrequestsexception")
94            || lower.contains("rate limit")
95            || lower.contains("too many requests")
96        {
97            return LlmErrorKind::RateLimited;
98        }
99        if lower.contains("accessdeniedexception")
100            || lower.contains("unrecognizedclientexception")
101            || lower.contains("expiredtokenexception")
102            || lower.contains("invalidsignatureexception")
103            || lower.contains("unauthorized")
104        {
105            return LlmErrorKind::Authentication;
106        }
107        if lower.contains("serviceunavailable")
108            || lower.contains("service unavailable")
109            || lower.contains("internalserverexception")
110            || lower.contains("modelnotreadyexception")
111        {
112            return LlmErrorKind::Unavailable;
113        }
114        LlmErrorKind::Other
115    }
116}
117
118/// LLM provider error with a semantic kind attached by the driver.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct LlmError {
121    pub kind: LlmErrorKind,
122    pub message: String,
123    /// Retries already consumed below the turn loop.
124    #[serde(default)]
125    pub retry_attempts: u32,
126    /// Backoff time already consumed below the turn loop.
127    #[serde(default)]
128    pub retry_wait_ms: u64,
129    /// Whether a lower provider layer already made the terminal retry decision.
130    #[serde(default)]
131    pub retry_handled: bool,
132}
133
134impl std::fmt::Display for LlmError {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        f.write_str(&self.message)
137    }
138}
139
140/// Errors that can occur during agent loop execution
141#[derive(Debug, Error)]
142pub enum AgentLoopError {
143    /// LLM provider error
144    #[error("LLM error: {0}")]
145    Llm(LlmError),
146
147    /// Request too large error (context length exceeded, token limits, etc.)
148    /// Contains the original error message for logging
149    #[error("Request too large: {0}")]
150    RequestTooLarge(String),
151
152    /// Model not available (404, model not found, access denied for model)
153    /// Contains the model_id string that was requested
154    #[error("Model not available: {0}")]
155    ModelNotAvailable(String),
156
157    /// Tool execution error
158    #[error("Tool execution error: {0}")]
159    ToolExecution(String),
160
161    /// Message store error
162    #[error("Message store error: {0}")]
163    MessageStore(String),
164
165    /// Event emission error
166    #[error("Event emission error: {0}")]
167    EventEmission(String),
168
169    /// Configuration error
170    #[error("Configuration error: {0}")]
171    Configuration(String),
172
173    /// Loop terminated due to max iterations
174    #[error("Max iterations ({0}) reached")]
175    MaxIterationsReached(usize),
176
177    /// Loop was cancelled
178    #[error("Loop cancelled")]
179    Cancelled,
180
181    /// No messages to process
182    #[error("No messages to process")]
183    NoMessages,
184
185    /// Agent not found
186    #[error("Agent not found: {0}")]
187    AgentNotFound(AgentId),
188
189    /// Harness not found
190    #[error("Harness not found: {0}")]
191    HarnessNotFound(HarnessId),
192
193    /// Session not found
194    #[error("Session not found: {0}")]
195    SessionNotFound(SessionId),
196
197    /// Internal error
198    #[error("Internal error: {0}")]
199    Internal(#[from] anyhow::Error),
200
201    /// Driver not registered for provider type
202    #[error(
203        "No driver registered for provider type '{0}'. Make sure the driver is registered at startup."
204    )]
205    DriverNotRegistered(String),
206}
207
208impl AgentLoopError {
209    /// Create an LLM error with no semantic kind (falls back to string
210    /// classification downstream).
211    pub fn llm(msg: impl Into<String>) -> Self {
212        AgentLoopError::Llm(LlmError {
213            kind: LlmErrorKind::Other,
214            message: msg.into(),
215            retry_attempts: 0,
216            retry_wait_ms: 0,
217            retry_handled: false,
218        })
219    }
220
221    /// Create an LLM error with a semantic kind assigned at the driver boundary.
222    pub fn llm_kind(kind: LlmErrorKind, msg: impl Into<String>) -> Self {
223        AgentLoopError::Llm(LlmError {
224            kind,
225            message: msg.into(),
226            retry_attempts: 0,
227            retry_wait_ms: 0,
228            retry_handled: false,
229        })
230    }
231
232    /// Attach retries already consumed by a lower provider layer. The reason
233    /// loop uses this to avoid multiplying attempt budgets across layers.
234    pub fn with_retry_metadata(mut self, metadata: &crate::llm_retry::RetryMetadata) -> Self {
235        if let AgentLoopError::Llm(error) = &mut self {
236            error.retry_attempts = metadata.attempts;
237            error.retry_wait_ms = metadata.total_retry_wait.as_millis() as u64;
238            error.retry_handled = true;
239        }
240        self
241    }
242
243    /// Number of lower-layer retries already consumed by this failure.
244    pub fn llm_retry_attempts(&self) -> u32 {
245        match self {
246            AgentLoopError::Llm(error) => error.retry_attempts,
247            _ => 0,
248        }
249    }
250
251    /// Whether a lower provider layer already exhausted or rejected recovery.
252    pub fn llm_retry_handled(&self) -> bool {
253        matches!(self, AgentLoopError::Llm(error) if error.retry_handled)
254    }
255
256    /// Get the semantic LLM error kind, if this is an LLM error.
257    pub fn llm_error_kind(&self) -> Option<LlmErrorKind> {
258        match self {
259            AgentLoopError::Llm(err) => Some(err.kind),
260            _ => None,
261        }
262    }
263
264    /// Create a tool execution error
265    pub fn tool(msg: impl Into<String>) -> Self {
266        AgentLoopError::ToolExecution(msg.into())
267    }
268
269    /// Create a message store error
270    pub fn store(msg: impl Into<String>) -> Self {
271        AgentLoopError::MessageStore(msg.into())
272    }
273
274    /// Create an event emission error
275    pub fn event(msg: impl Into<String>) -> Self {
276        AgentLoopError::EventEmission(msg.into())
277    }
278
279    /// Create a configuration error
280    pub fn config(msg: impl Into<String>) -> Self {
281        AgentLoopError::Configuration(msg.into())
282    }
283
284    /// Create an agent not found error
285    pub fn agent_not_found(agent_id: AgentId) -> Self {
286        AgentLoopError::AgentNotFound(agent_id)
287    }
288
289    /// Create a harness not found error
290    pub fn harness_not_found(harness_id: HarnessId) -> Self {
291        AgentLoopError::HarnessNotFound(harness_id)
292    }
293
294    /// Create a session not found error
295    pub fn session_not_found(session_id: SessionId) -> Self {
296        AgentLoopError::SessionNotFound(session_id)
297    }
298
299    /// Create a driver not registered error
300    pub fn driver_not_registered(provider_type: impl Into<String>) -> Self {
301        AgentLoopError::DriverNotRegistered(provider_type.into())
302    }
303
304    /// Create a request too large error
305    pub fn request_too_large(msg: impl Into<String>) -> Self {
306        AgentLoopError::RequestTooLarge(msg.into())
307    }
308
309    /// Create a model not available error
310    pub fn model_not_available(model_id: impl Into<String>) -> Self {
311        AgentLoopError::ModelNotAvailable(model_id.into())
312    }
313
314    /// Check if this is a request-too-large error
315    pub fn is_request_too_large(&self) -> bool {
316        matches!(self, AgentLoopError::RequestTooLarge(_))
317    }
318
319    /// Check if this is a model-not-available error
320    pub fn is_model_not_available(&self) -> bool {
321        matches!(self, AgentLoopError::ModelNotAvailable(_))
322    }
323
324    /// Get the model ID if this is a model-not-available error
325    pub fn model_not_available_id(&self) -> Option<&str> {
326        match self {
327            AgentLoopError::ModelNotAvailable(id) => Some(id),
328            _ => None,
329        }
330    }
331
332    /// Check if this is a rate-limit error (semantic kind, or HTTP 429 /
333    /// rate-limit keywords for untyped errors)
334    pub fn is_rate_limited(&self) -> bool {
335        match self {
336            AgentLoopError::Llm(err) => match err.kind {
337                LlmErrorKind::RateLimited => true,
338                LlmErrorKind::Other => {
339                    let msg_lower = err.message.to_ascii_lowercase();
340                    msg_lower.contains("(429)")
341                        || msg_lower.contains("rate limit")
342                        || msg_lower.contains("too many requests")
343                }
344                _ => false,
345            },
346            _ => false,
347        }
348    }
349
350    /// Check if this is an authentication/authorization error (HTTP 401/403)
351    pub fn is_auth_error(&self) -> bool {
352        match self {
353            AgentLoopError::Llm(err) => match err.kind {
354                LlmErrorKind::Authentication => true,
355                LlmErrorKind::Other => {
356                    err.message.contains("(401)") || err.message.contains("(403)")
357                }
358                _ => false,
359            },
360            _ => false,
361        }
362    }
363
364    /// Check if this is a server error (HTTP 5xx or transient provider issue)
365    pub fn is_server_error(&self) -> bool {
366        match self {
367            AgentLoopError::Llm(err) => match err.kind {
368                LlmErrorKind::Unavailable => true,
369                LlmErrorKind::Other => {
370                    let msg = &err.message;
371                    msg.contains("(500)")
372                        || msg.contains("(502)")
373                        || msg.contains("(503)")
374                        || msg.contains("(504)")
375                        || msg.contains("(529)")
376                }
377                _ => false,
378            },
379            _ => false,
380        }
381    }
382
383    /// Check whether an LLM failure is safe to retry.
384    ///
385    /// Semantic driver classification is authoritative. Untyped legacy errors
386    /// retain the message-based fallback until all drivers preserve structure.
387    pub fn is_transient_llm_error(&self) -> bool {
388        match self {
389            AgentLoopError::Llm(err) => match err.kind {
390                LlmErrorKind::RateLimited | LlmErrorKind::Unavailable => true,
391                LlmErrorKind::Authentication
392                | LlmErrorKind::QuotaExhausted
393                | LlmErrorKind::InvalidRequest => false,
394                LlmErrorKind::Other => crate::llm_retry::is_transient_error_message(&err.message),
395            },
396            _ => false,
397        }
398    }
399
400    /// Check if this error is deterministic and should never be retried.
401    ///
402    /// Non-retryable errors reference data that is permanently gone (e.g. a
403    /// deleted message, a missing agent). Retrying will never succeed and only
404    /// burns attempts while keeping the workflow stuck.
405    ///
406    /// Note: the durable worker currently uses string-matching via
407    /// `is_non_retryable_task_error` because task errors arrive as strings.
408    /// This method provides the typed equivalent for callers that have access
409    /// to a structured `AgentLoopError`.
410    pub fn is_non_retryable(&self) -> bool {
411        match self {
412            // Missing data is permanent — the entity was deleted.
413            AgentLoopError::AgentNotFound(_)
414            | AgentLoopError::HarnessNotFound(_)
415            | AgentLoopError::SessionNotFound(_)
416            | AgentLoopError::NoMessages => true,
417
418            // Config/driver errors won't self-heal within retries.
419            AgentLoopError::Configuration(_) | AgentLoopError::DriverNotRegistered(_) => true,
420
421            // MessageStore "not found" errors (deleted messages).
422            AgentLoopError::MessageStore(msg) => msg.to_ascii_lowercase().contains("not found"),
423
424            // Everything else is potentially transient.
425            _ => false,
426        }
427    }
428
429    /// Get user-facing error message based on error classification
430    pub fn user_facing_message(&self) -> String {
431        self.user_facing_error(UserFacingErrorContext::default())
432            .fallback_message()
433    }
434
435    /// Get structured user-facing error metadata based on error classification.
436    pub fn user_facing_error(&self, context: UserFacingErrorContext) -> UserFacingError {
437        match self {
438            AgentLoopError::ModelNotAvailable(model_id) => {
439                UserFacingError::new(user_facing_error_codes::MODEL_UNAVAILABLE)
440                    .with_field("model_id", model_id)
441                    .with_optional_field("provider", context.provider)
442            }
443            AgentLoopError::RequestTooLarge(_) => {
444                UserFacingError::new(user_facing_error_codes::REQUEST_TOO_LARGE)
445                    .with_optional_field("provider", context.provider)
446                    .with_optional_field("model_id", context.model_id)
447            }
448            AgentLoopError::MaxIterationsReached(max_iterations) => {
449                UserFacingError::new(user_facing_error_codes::MAX_ITERATIONS)
450                    .with_field("max_iterations", max_iterations)
451            }
452            AgentLoopError::Llm(err) => {
453                // Prefer the semantic kind the driver assigned at the provider
454                // boundary; fall back to string classification for untyped
455                // errors so legacy paths keep working.
456                let code = match err.kind {
457                    LlmErrorKind::Authentication => {
458                        Some(user_facing_error_codes::PROVIDER_MISCONFIGURED)
459                    }
460                    LlmErrorKind::QuotaExhausted => {
461                        Some(user_facing_error_codes::PROVIDER_QUOTA_EXHAUSTED)
462                    }
463                    LlmErrorKind::RateLimited => {
464                        Some(user_facing_error_codes::PROVIDER_RATE_LIMITED)
465                    }
466                    LlmErrorKind::Unavailable => {
467                        Some(user_facing_error_codes::PROVIDER_UNAVAILABLE)
468                    }
469                    LlmErrorKind::InvalidRequest | LlmErrorKind::Other => None,
470                };
471                match code {
472                    Some(code) => {
473                        let error = UserFacingError::new(code)
474                            .with_optional_field("provider", context.provider)
475                            .with_optional_field("model_id", context.model_id);
476                        if code == user_facing_error_codes::PROVIDER_RATE_LIMITED {
477                            error.with_optional_field("retry_after", context.retry_after)
478                        } else {
479                            error
480                        }
481                    }
482                    None => classify_runtime_error_message(&err.message, &context),
483                }
484            }
485            _ => UserFacingError::new(user_facing_error_codes::PROCESSING_ERROR)
486                .with_optional_field("provider", context.provider)
487                .with_optional_field("model_id", context.model_id),
488        }
489    }
490}
491
492// ============================================================================
493// Store Result Extension Trait
494// ============================================================================
495
496/// Extension trait that converts any `Result<T, E: Display>` into `Result<T, AgentLoopError>`
497/// via `AgentLoopError::store(e.to_string())`.
498///
499/// Replaces the boilerplate pattern:
500/// ```ignore
501/// .map_err(|e| AgentLoopError::store(e.to_string()))?
502/// ```
503/// with:
504/// ```ignore
505/// .store_err()?
506/// ```
507pub trait StoreResultExt<T> {
508    fn store_err(self) -> Result<T>;
509}
510
511impl<T, E: std::fmt::Display> StoreResultExt<T> for std::result::Result<T, E> {
512    fn store_err(self) -> Result<T> {
513        self.map_err(|e| AgentLoopError::store(e.to_string()))
514    }
515}
516
517// ============================================================================
518// SessionFileSystem error classification (EVE-645)
519// ============================================================================
520
521/// Typed classification of a `SessionFileSystem` failure.
522///
523/// The file-system tools (`crates/core/src/capabilities/file_system.rs`) decide
524/// whether a failure is a *tool error* (surfaced to the agent verbatim — bad
525/// input it can correct) or an *internal error* (logged, generic copy). They
526/// previously made that call with `msg.contains("readonly")` / `"is a
527/// directory"` / `"not found"` style sniffs against the stringified error.
528///
529/// The `SessionFileSystem` trait returns `anyhow::Result<T>` and has 10+
530/// implementors across crates, so widening the trait's error type is out of
531/// scope. Instead, [`classify_fs_error`] gives a single typed seam: it
532/// downcasts to [`FileSystemError`] when an implementor opts in, and otherwise
533/// falls back to the legacy substring heuristics in one place. Implementors can
534/// migrate to returning `FileSystemError` (via `anyhow::Error::new`)
535/// incrementally without changing behavior.
536#[derive(Debug, Clone, Copy, PartialEq, Eq)]
537pub enum FileSystemErrorClass {
538    /// The target (or a path component) does not exist.
539    NotFound,
540    /// The target is read-only and cannot be written or deleted.
541    ReadOnly,
542    /// Expected a file but the path is a directory.
543    IsADirectory,
544    /// Expected a directory but the path is not one.
545    NotADirectory,
546    /// A non-recursive delete refused a non-empty directory.
547    NotEmpty,
548    /// No recognized client-correctable condition; treat as internal.
549    Other,
550}
551
552/// Typed `SessionFileSystem` error. Implementors may return this (wrapped in
553/// `anyhow::Error`) so [`classify_fs_error`] resolves the class without string
554/// matching. Each variant carries the human-facing message so the file tools
555/// can keep surfacing the same text to the agent.
556#[derive(Debug, Error)]
557pub enum FileSystemError {
558    #[error("{0}")]
559    NotFound(String),
560    #[error("{0}")]
561    ReadOnly(String),
562    #[error("{0}")]
563    IsADirectory(String),
564    #[error("{0}")]
565    NotADirectory(String),
566    #[error("{0}")]
567    NotEmpty(String),
568}
569
570impl FileSystemError {
571    fn class(&self) -> FileSystemErrorClass {
572        match self {
573            FileSystemError::NotFound(_) => FileSystemErrorClass::NotFound,
574            FileSystemError::ReadOnly(_) => FileSystemErrorClass::ReadOnly,
575            FileSystemError::IsADirectory(_) => FileSystemErrorClass::IsADirectory,
576            FileSystemError::NotADirectory(_) => FileSystemErrorClass::NotADirectory,
577            FileSystemError::NotEmpty(_) => FileSystemErrorClass::NotEmpty,
578        }
579    }
580}
581
582/// Classify a `SessionFileSystem` failure into a [`FileSystemErrorClass`].
583///
584/// Prefers a typed [`FileSystemError`] in the error chain; falls back to the
585/// legacy substring heuristics (the single remaining place they live) so
586/// untyped implementors keep their current routing. Behavior is identical to
587/// the previous inline `msg.contains(...)` checks in `file_system.rs`:
588/// "readonly" and "is a directory" mark client-correctable write failures,
589/// "not found" / "not a directory" mark client-correctable read failures, and
590/// "not empty" / "recursive" mark client-correctable delete failures.
591pub fn classify_fs_error<E>(err: &E) -> FileSystemErrorClass
592where
593    E: std::error::Error + 'static,
594{
595    // Prefer a typed FileSystemError anywhere in the source chain so an
596    // implementor that opts in is classified without string matching. Works
597    // whether the error is a bare FileSystemError or wrapped (e.g. inside
598    // `AgentLoopError::Internal(anyhow!(FileSystemError::..))`).
599    let mut source: Option<&(dyn std::error::Error + 'static)> = Some(err);
600    while let Some(current) = source {
601        if let Some(typed) = current.downcast_ref::<FileSystemError>() {
602            return typed.class();
603        }
604        source = current.source();
605    }
606
607    let msg = err.to_string();
608    // Note: real-disk backends emit "read-only" (hyphenated); the legacy check
609    // only matched "readonly", so we preserve that exact behavior rather than
610    // silently widening it.
611    if msg.contains("readonly") {
612        FileSystemErrorClass::ReadOnly
613    } else if msg.contains("is a directory") {
614        FileSystemErrorClass::IsADirectory
615    } else if msg.contains("not a directory") {
616        FileSystemErrorClass::NotADirectory
617    } else if msg.contains("not empty") || msg.contains("recursive") {
618        FileSystemErrorClass::NotEmpty
619    } else if msg.contains("not found") {
620        FileSystemErrorClass::NotFound
621    } else {
622        FileSystemErrorClass::Other
623    }
624}
625
626// ============================================================================
627// JSON Helpers
628// ============================================================================
629
630/// Convert a serializable value to `serde_json::Value`, falling back to `Value::Null` on error.
631///
632/// Replaces the boilerplate pattern:
633/// ```ignore
634/// serde_json::to_value(&x).unwrap_or_default()
635/// ```
636pub fn json_val<T: Serialize>(value: &T) -> serde_json::Value {
637    serde_json::to_value(value).unwrap_or_default()
638}
639
640/// Deserialize a `serde_json::Value` into `T`, falling back to `T::default()` on error.
641///
642/// Replaces the boilerplate pattern:
643/// ```ignore
644/// serde_json::from_value(v).unwrap_or_default()
645/// ```
646pub fn from_json<T: DeserializeOwned + Default>(value: serde_json::Value) -> T {
647    serde_json::from_value(value).unwrap_or_default()
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653
654    // EVE-645: classify_fs_error must prefer the typed FileSystemError and
655    // otherwise reproduce the exact substring routing the file tools used to
656    // inline. These cases pin both paths against the real producer messages.
657    #[test]
658    fn classify_fs_error_prefers_typed_variant() {
659        let err = FileSystemError::ReadOnly("x".into());
660        assert_eq!(classify_fs_error(&err), FileSystemErrorClass::ReadOnly);
661        let err = FileSystemError::IsADirectory("x".into());
662        assert_eq!(classify_fs_error(&err), FileSystemErrorClass::IsADirectory);
663    }
664
665    #[test]
666    fn classify_fs_error_substring_fallback_matches_real_producers() {
667        // Real producers raise these as `AgentLoopError::store(...)`, whose
668        // Display is "Message store error: <msg>" — the substrings still match.
669        let cases = [
670            (
671                "Cannot modify readonly file: /a",
672                FileSystemErrorClass::ReadOnly,
673            ),
674            (
675                "Cannot delete readonly file: /a",
676                FileSystemErrorClass::ReadOnly,
677            ),
678            (
679                "write target is a directory: /a",
680                FileSystemErrorClass::IsADirectory,
681            ),
682            (
683                "Path is not a directory: /a",
684                FileSystemErrorClass::NotADirectory,
685            ),
686            (
687                "workspace root is not a directory: /a",
688                FileSystemErrorClass::NotADirectory,
689            ),
690            ("Directory not found: /a", FileSystemErrorClass::NotFound),
691            (
692                "Directory is not empty. Use recursive=true to delete",
693                FileSystemErrorClass::NotEmpty,
694            ),
695            (
696                "Cannot delete root directory without recursive flag",
697                FileSystemErrorClass::NotEmpty,
698            ),
699            (
700                "recursive delete failed for /a: io",
701                FileSystemErrorClass::NotEmpty,
702            ),
703            ("disk full", FileSystemErrorClass::Other),
704        ];
705        for (msg, expected) in cases {
706            let err = AgentLoopError::store(msg);
707            assert_eq!(classify_fs_error(&err), expected, "msg: {msg}");
708        }
709    }
710
711    // A typed FileSystemError returned directly (the seam an implementor opts
712    // into) is classified without touching the message text.
713    #[test]
714    fn classify_fs_error_classifies_typed_directly() {
715        let err = FileSystemError::NotEmpty("anything at all".into());
716        assert_eq!(classify_fs_error(&err), FileSystemErrorClass::NotEmpty);
717    }
718
719    // The hyphenated "read-only" from real-disk backends did NOT match the
720    // legacy "readonly" check and must not now; preserve that exactly.
721    #[test]
722    fn classify_fs_error_does_not_match_hyphenated_read_only() {
723        let err = AgentLoopError::store("file is read-only: /a");
724        assert_eq!(classify_fs_error(&err), FileSystemErrorClass::Other);
725    }
726
727    #[test]
728    fn test_is_request_too_large_returns_true_for_typed_error() {
729        let err = AgentLoopError::request_too_large("context length exceeded");
730        assert!(err.is_request_too_large());
731    }
732
733    #[test]
734    fn test_is_request_too_large_returns_false_for_llm_error() {
735        let err = AgentLoopError::llm("OpenAI API error (500): Internal server error");
736        assert!(!err.is_request_too_large());
737    }
738
739    #[test]
740    fn test_is_request_too_large_returns_false_for_other_errors() {
741        let err = AgentLoopError::ToolExecution("some error".to_string());
742        assert!(!err.is_request_too_large());
743
744        let err = AgentLoopError::Cancelled;
745        assert!(!err.is_request_too_large());
746    }
747
748    #[test]
749    fn test_request_too_large_error_preserves_message() {
750        let original_msg = "OpenAI API error (429): Request too large for gpt-4";
751        let err = AgentLoopError::request_too_large(original_msg);
752        assert_eq!(
753            err.to_string(),
754            format!("Request too large: {}", original_msg)
755        );
756    }
757
758    #[test]
759    fn test_is_model_not_available_returns_true_for_typed_error() {
760        let err = AgentLoopError::model_not_available("claude-sonnet-4-6-20260217");
761        assert!(err.is_model_not_available());
762        assert_eq!(
763            err.model_not_available_id(),
764            Some("claude-sonnet-4-6-20260217")
765        );
766    }
767
768    #[test]
769    fn test_is_model_not_available_returns_false_for_llm_error() {
770        let err = AgentLoopError::llm("some error");
771        assert!(!err.is_model_not_available());
772        assert_eq!(err.model_not_available_id(), None);
773    }
774
775    #[test]
776    fn test_model_not_available_error_display() {
777        let err = AgentLoopError::model_not_available("gpt-99");
778        assert_eq!(err.to_string(), "Model not available: gpt-99");
779    }
780
781    #[test]
782    fn test_is_rate_limited_detects_429() {
783        let err = AgentLoopError::llm("Anthropic API error (429): rate limit exceeded");
784        assert!(err.is_rate_limited());
785    }
786
787    #[test]
788    fn test_is_rate_limited_detects_rate_limit_keyword() {
789        let err =
790            AgentLoopError::llm("Rate limit exceeded (after 2 retries, last error: too many)");
791        assert!(err.is_rate_limited());
792    }
793
794    #[test]
795    fn test_is_rate_limited_false_for_server_error() {
796        let err = AgentLoopError::llm("Anthropic API error (500): internal server error");
797        assert!(!err.is_rate_limited());
798    }
799
800    #[test]
801    fn test_is_auth_error_detects_401() {
802        let err = AgentLoopError::llm("Anthropic API error (401): invalid api key");
803        assert!(err.is_auth_error());
804    }
805
806    #[test]
807    fn test_is_auth_error_detects_403() {
808        let err = AgentLoopError::llm("OpenAI API error (403): forbidden");
809        assert!(err.is_auth_error());
810    }
811
812    #[test]
813    fn test_is_server_error_detects_500() {
814        let err = AgentLoopError::llm("Anthropic API error (500): internal server error");
815        assert!(err.is_server_error());
816    }
817
818    #[test]
819    fn test_is_server_error_detects_503() {
820        let err = AgentLoopError::llm("OpenAI API error (503): service unavailable");
821        assert!(err.is_server_error());
822    }
823
824    #[test]
825    fn test_user_facing_message_rate_limited() {
826        let err = AgentLoopError::llm("Anthropic API error (429): rate limit exceeded");
827        assert_eq!(
828            err.user_facing_message(),
829            "Rate limited by the AI provider. Please wait a moment."
830        );
831    }
832
833    #[test]
834    fn test_user_facing_message_auth_error() {
835        let err = AgentLoopError::llm("Anthropic API error (401): invalid api key");
836        assert_eq!(
837            err.user_facing_message(),
838            "There is a misconfiguration with the AI provider. Please contact support."
839        );
840    }
841
842    #[test]
843    fn test_user_facing_message_server_error() {
844        let err = AgentLoopError::llm("Anthropic API error (500): internal server error");
845        assert_eq!(
846            err.user_facing_message(),
847            "The AI provider is experiencing issues. Please try again shortly."
848        );
849    }
850
851    #[test]
852    fn test_user_facing_message_generic_fallback() {
853        let err = AgentLoopError::llm("Failed to send request: connection refused");
854        assert_eq!(
855            err.user_facing_message(),
856            "I encountered an error while processing your request. Please try again later."
857        );
858    }
859
860    #[test]
861    fn test_user_facing_message_model_not_available() {
862        let err = AgentLoopError::model_not_available("gpt-99");
863        assert!(err.user_facing_message().contains("gpt-99"));
864        assert!(err.user_facing_message().contains("not available"));
865    }
866
867    #[test]
868    fn test_user_facing_message_request_too_large() {
869        let err = AgentLoopError::request_too_large("context length exceeded");
870        assert!(err.user_facing_message().contains("too long"));
871    }
872
873    #[test]
874    fn test_user_facing_error_model_not_available_includes_model_id() {
875        let err = AgentLoopError::model_not_available("gpt-99");
876        let user_error = err.user_facing_error(UserFacingErrorContext::default());
877
878        assert_eq!(user_error.code, user_facing_error_codes::MODEL_UNAVAILABLE);
879        assert_eq!(
880            user_error.fields.get("model_id"),
881            Some(&serde_json::Value::String("gpt-99".to_string()))
882        );
883    }
884
885    #[test]
886    fn test_user_facing_error_rate_limited_includes_provider_context() {
887        let err = AgentLoopError::llm("Anthropic API error (429): rate limit exceeded");
888        let user_error = err.user_facing_error(
889            UserFacingErrorContext::default()
890                .with_provider("anthropic")
891                .with_model_id("claude-sonnet-4-5")
892                .with_retry_after(12),
893        );
894
895        assert_eq!(
896            user_error.code,
897            user_facing_error_codes::PROVIDER_RATE_LIMITED
898        );
899        assert_eq!(
900            user_error.fields.get("provider"),
901            Some(&serde_json::Value::String("anthropic".to_string()))
902        );
903        assert_eq!(
904            user_error.fields.get("model_id"),
905            Some(&serde_json::Value::String("claude-sonnet-4-5".to_string()))
906        );
907        assert_eq!(
908            user_error.fields.get("retry_after"),
909            Some(&serde_json::json!(12))
910        );
911    }
912
913    #[test]
914    fn test_llm_error_kind_from_provider_status() {
915        assert_eq!(
916            LlmErrorKind::from_provider_status(401, "invalid x-api-key"),
917            LlmErrorKind::Authentication
918        );
919        assert_eq!(
920            LlmErrorKind::from_provider_status(403, "forbidden"),
921            LlmErrorKind::Authentication
922        );
923        assert_eq!(
924            LlmErrorKind::from_provider_status(429, "rate limit exceeded"),
925            LlmErrorKind::RateLimited
926        );
927        // Quota patterns win over the 429 status.
928        assert_eq!(
929            LlmErrorKind::from_provider_status(
930                429,
931                "{\"error\":{\"type\":\"insufficient_quota\"}}"
932            ),
933            LlmErrorKind::QuotaExhausted
934        );
935        assert_eq!(
936            LlmErrorKind::from_provider_status(
937                429,
938                "{\"error\":{\"type\":\"usage_limit_reached\"}}"
939            ),
940            LlmErrorKind::QuotaExhausted
941        );
942        // Anthropic reports exhausted billing as a 400.
943        assert_eq!(
944            LlmErrorKind::from_provider_status(
945                400,
946                "Your credit balance is too low to access the Anthropic API."
947            ),
948            LlmErrorKind::QuotaExhausted
949        );
950        assert_eq!(
951            LlmErrorKind::from_provider_status(529, "overloaded"),
952            LlmErrorKind::Unavailable
953        );
954        assert_eq!(
955            LlmErrorKind::from_provider_status(503, "unavailable"),
956            LlmErrorKind::Unavailable
957        );
958        assert_eq!(
959            LlmErrorKind::from_provider_status(400, "bad request"),
960            LlmErrorKind::InvalidRequest
961        );
962    }
963
964    #[test]
965    fn test_llm_error_kind_from_error_text_bedrock() {
966        assert_eq!(
967            LlmErrorKind::from_error_text("ThrottlingException: Too many requests"),
968            LlmErrorKind::RateLimited
969        );
970        assert_eq!(
971            LlmErrorKind::from_error_text("AccessDeniedException: not authorized"),
972            LlmErrorKind::Authentication
973        );
974        assert_eq!(
975            LlmErrorKind::from_error_text("ServiceUnavailableException"),
976            LlmErrorKind::Unavailable
977        );
978        assert_eq!(
979            LlmErrorKind::from_error_text("usage_limit_reached; resets_at=1783767823"),
980            LlmErrorKind::QuotaExhausted
981        );
982        assert_eq!(
983            LlmErrorKind::from_error_text("something else entirely"),
984            LlmErrorKind::Other
985        );
986    }
987
988    #[test]
989    fn test_user_facing_error_prefers_semantic_kind() {
990        // The message alone would string-classify as rate-limited ("429"),
991        // but the driver-assigned kind must win.
992        let err = AgentLoopError::llm_kind(
993            LlmErrorKind::QuotaExhausted,
994            "OpenAI API error (429): insufficient_quota",
995        );
996        let user_error =
997            err.user_facing_error(UserFacingErrorContext::default().with_provider("openai"));
998        assert_eq!(
999            user_error.code,
1000            user_facing_error_codes::PROVIDER_QUOTA_EXHAUSTED
1001        );
1002        assert_eq!(
1003            user_error.fields.get("provider"),
1004            Some(&serde_json::Value::String("openai".to_string()))
1005        );
1006
1007        let err = AgentLoopError::llm_kind(LlmErrorKind::Authentication, "bad key");
1008        assert_eq!(
1009            err.user_facing_error(UserFacingErrorContext::default())
1010                .code,
1011            user_facing_error_codes::PROVIDER_MISCONFIGURED
1012        );
1013
1014        let err = AgentLoopError::llm_kind(LlmErrorKind::RateLimited, "slow down");
1015        let user_error =
1016            err.user_facing_error(UserFacingErrorContext::default().with_retry_after(5));
1017        assert_eq!(
1018            user_error.code,
1019            user_facing_error_codes::PROVIDER_RATE_LIMITED
1020        );
1021        assert_eq!(user_error.fields.get("retry_after"), Some(&json_val(&5)));
1022
1023        let err = AgentLoopError::llm_kind(LlmErrorKind::Unavailable, "overloaded");
1024        assert_eq!(
1025            err.user_facing_error(UserFacingErrorContext::default())
1026                .code,
1027            user_facing_error_codes::PROVIDER_UNAVAILABLE
1028        );
1029    }
1030
1031    #[test]
1032    fn test_semantic_kind_drives_predicates() {
1033        assert!(AgentLoopError::llm_kind(LlmErrorKind::RateLimited, "x").is_rate_limited());
1034        assert!(AgentLoopError::llm_kind(LlmErrorKind::Authentication, "x").is_auth_error());
1035        assert!(AgentLoopError::llm_kind(LlmErrorKind::Unavailable, "x").is_server_error());
1036        // Untyped errors keep the legacy string behavior.
1037        assert!(AgentLoopError::llm("error (429)").is_rate_limited());
1038        assert!(
1039            !AgentLoopError::llm_kind(LlmErrorKind::Authentication, "error (429)")
1040                .is_rate_limited()
1041        );
1042    }
1043
1044    #[test]
1045    fn test_store_result_ext_ok() {
1046        let result: std::result::Result<i32, String> = Ok(42);
1047        assert_eq!(result.store_err().unwrap(), 42);
1048    }
1049
1050    #[test]
1051    fn test_store_result_ext_err() {
1052        let result: std::result::Result<i32, String> = Err("db error".to_string());
1053        let err = result.store_err().unwrap_err();
1054        assert!(matches!(err, AgentLoopError::MessageStore(_)));
1055        assert!(err.to_string().contains("db error"));
1056    }
1057
1058    #[test]
1059    fn test_json_val() {
1060        let v = json_val(&vec![1, 2, 3]);
1061        assert_eq!(v, serde_json::json!([1, 2, 3]));
1062    }
1063
1064    #[test]
1065    fn test_from_json() {
1066        let v = serde_json::json!(["a", "b"]);
1067        let result: Vec<String> = from_json(v);
1068        assert_eq!(result, vec!["a", "b"]);
1069    }
1070
1071    #[test]
1072    fn test_from_json_default_on_mismatch() {
1073        let v = serde_json::json!("not a number");
1074        let result: i32 = from_json(v);
1075        assert_eq!(result, 0);
1076    }
1077}