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