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