Skip to main content

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