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