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