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    AttestationRequirement, UserFacingError, UserFacingErrorContext,
9    classify_runtime_error_message, codes as user_facing_error_codes,
10    is_attestation_required_message, is_provider_quota_message, is_usage_limit_message,
11    parse_attestation_requirement,
12};
13use serde::{Deserialize, Serialize, de::DeserializeOwned};
14use thiserror::Error;
15
16/// Result type alias for agent loop operations
17pub type Result<T> = std::result::Result<T, AgentLoopError>;
18
19/// Semantic classification of an LLM provider error, assigned by the driver
20/// at the provider boundary where the HTTP status and response body are still
21/// available. Downstream consumers prefer this over re-parsing error strings;
22/// `LlmErrorKind::Other` falls back to string classification
23/// (`classify_runtime_error_message`) so untyped errors keep working.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum LlmErrorKind {
27    /// Invalid or missing credentials, or access denied (401/403, bad API key).
28    Authentication,
29    /// Provider account is out of credits/quota (billing). Non-transient:
30    /// needs operator action, unlike a regular rate limit.
31    QuotaExhausted,
32    /// Transient rate limit (429).
33    RateLimited,
34    /// Provider outage or unreachable (5xx, 529, network failure).
35    Unavailable,
36    /// Provider account has not completed a confirmation the model requires
37    /// (OpenRouter's 18+ age gate). Non-transient and not a credential
38    /// problem: it clears when the account holder completes the confirmation,
39    /// so it is kept apart from `Authentication` even though it arrives as a
40    /// 403.
41    AttestationRequired,
42    /// Provider rejected the request shape (4xx that is not auth/quota/429).
43    InvalidRequest,
44    /// Unclassified; downstream falls back to string classification.
45    Other,
46}
47
48impl LlmErrorKind {
49    /// Classify a provider's stable machine-readable error code.
50    pub fn from_provider_code(code: &str) -> Option<Self> {
51        let code = code.trim().to_ascii_lowercase();
52        match code.as_str() {
53            "insufficient_quota"
54            | "billing_hard_limit_reached"
55            | "credit_balance_too_low"
56            | "credit_balance_exhausted" => Some(Self::QuotaExhausted),
57            "authentication_error" | "invalid_api_key" | "permission_denied" => {
58                Some(Self::Authentication)
59            }
60            "rate_limit_exceeded" | "rate_limit_error" | "overloaded_error" => {
61                Some(Self::RateLimited)
62            }
63            "server_error"
64            | "internal_error"
65            | "processing_error"
66            | "service_unavailable"
67            | "timeout" => Some(Self::Unavailable),
68            "invalid_request_error" | "model_not_found" => Some(Self::InvalidRequest),
69            _ => None,
70        }
71    }
72
73    /// Classify a provider HTTP error from status code + response body.
74    ///
75    /// Quota/billing patterns are checked before the status code because
76    /// providers surface exhausted billing under different statuses
77    /// (OpenAI: 429 `insufficient_quota`, Anthropic: 400 "credit balance is
78    /// too low").
79    pub fn from_provider_status(status: u16, body: &str) -> Self {
80        if is_provider_quota_message(body) || is_usage_limit_message(body) {
81            return LlmErrorKind::QuotaExhausted;
82        }
83        // Body-driven for the same reason as quota: the 403 this arrives under
84        // is indistinguishable from a bad-key 403 by status alone, and the
85        // gate is worth naming only when the body actually reports one.
86        if is_attestation_required_message(body) {
87            return LlmErrorKind::AttestationRequired;
88        }
89        match status {
90            401 | 403 => LlmErrorKind::Authentication,
91            429 => LlmErrorKind::RateLimited,
92            408 | 409 => LlmErrorKind::Unavailable,
93            501 => LlmErrorKind::Other,
94            500..=599 => LlmErrorKind::Unavailable,
95            400..=499 => LlmErrorKind::InvalidRequest,
96            _ => LlmErrorKind::Other,
97        }
98    }
99
100    /// Keyword-based classification for drivers without an HTTP status at the
101    /// error site (e.g. Bedrock SDK errors).
102    pub fn from_error_text(text: &str) -> Self {
103        if is_provider_quota_message(text) || is_usage_limit_message(text) {
104            return LlmErrorKind::QuotaExhausted;
105        }
106        let lower = text.to_ascii_lowercase();
107        if lower.contains("throttlingexception")
108            || lower.contains("toomanyrequestsexception")
109            || lower.contains("rate limit")
110            || lower.contains("too many requests")
111        {
112            return LlmErrorKind::RateLimited;
113        }
114        if lower.contains("accessdeniedexception")
115            || lower.contains("unrecognizedclientexception")
116            || lower.contains("expiredtokenexception")
117            || lower.contains("invalidsignatureexception")
118            || lower.contains("unauthorized")
119        {
120            return LlmErrorKind::Authentication;
121        }
122        if lower.contains("serviceunavailable")
123            || lower.contains("service unavailable")
124            || lower.contains("internalserverexception")
125            || lower.contains("modelnotreadyexception")
126        {
127            return LlmErrorKind::Unavailable;
128        }
129        LlmErrorKind::Other
130    }
131}
132
133/// LLM provider error with a semantic kind attached by the driver.
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct LlmError {
136    pub kind: LlmErrorKind,
137    pub message: String,
138    /// Retries already consumed below the turn loop.
139    #[serde(default)]
140    pub retry_attempts: u32,
141    /// Backoff time already consumed below the turn loop.
142    #[serde(default)]
143    pub retry_wait_ms: u64,
144    /// Whether a lower provider layer already made the terminal retry decision.
145    #[serde(default)]
146    pub retry_handled: bool,
147}
148
149impl std::fmt::Display for LlmError {
150    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        f.write_str(&self.message)
152    }
153}
154
155/// Errors that can occur during agent loop execution
156#[derive(Debug, Error)]
157pub enum AgentLoopError {
158    /// LLM provider error
159    #[error("LLM error: {0}")]
160    Llm(LlmError),
161
162    /// Request too large error (context length exceeded, token limits, etc.)
163    /// Contains the original error message for logging
164    #[error("Request too large: {0}")]
165    RequestTooLarge(String),
166
167    /// Model not available (404, model not found, access denied for model)
168    /// Contains the model_id string that was requested
169    #[error("Model not available: {0}")]
170    ModelNotAvailable(String),
171
172    /// No explicit, snapshot, or system-default model could be resolved.
173    #[error("Model not configured")]
174    ModelNotConfigured,
175
176    /// Tool execution error
177    #[error("Tool execution error: {0}")]
178    ToolExecution(String),
179
180    /// Message store error
181    #[error("Message store error: {0}")]
182    MessageStore(String),
183
184    /// Event emission error
185    #[error("Event emission error: {0}")]
186    EventEmission(String),
187
188    /// Configuration error
189    #[error("Configuration error: {0}")]
190    Configuration(String),
191
192    /// Loop terminated due to max iterations
193    #[error("Max iterations ({0}) reached")]
194    MaxIterationsReached(usize),
195
196    /// Loop was cancelled
197    #[error("Loop cancelled")]
198    Cancelled,
199
200    /// No messages to process
201    #[error("No messages to process")]
202    NoMessages,
203
204    /// Agent not found
205    #[error("Agent not found: {0}")]
206    AgentNotFound(AgentId),
207
208    /// Harness not found
209    #[error("Harness not found: {0}")]
210    HarnessNotFound(HarnessId),
211
212    /// Session not found
213    #[error("Session not found: {0}")]
214    SessionNotFound(SessionId),
215
216    /// Internal error
217    #[error("Internal error: {0}")]
218    Internal(#[from] anyhow::Error),
219
220    /// Driver not registered for provider type
221    #[error(
222        "No driver registered for provider type '{0}'. Make sure the driver is registered at startup."
223    )]
224    DriverNotRegistered(String),
225}
226
227impl AgentLoopError {
228    /// Prefix provider-bound messages without changing structured identifiers.
229    pub fn with_provider(mut self, provider: &str) -> Self {
230        let prefix = format!("provider '{provider}': ");
231        match &mut self {
232            AgentLoopError::Llm(error) if !error.message.starts_with(&prefix) => {
233                error.message.insert_str(0, &prefix)
234            }
235            AgentLoopError::RequestTooLarge(message) | AgentLoopError::Configuration(message)
236                if !message.starts_with(&prefix) =>
237            {
238                message.insert_str(0, &prefix)
239            }
240            // ModelNotAvailable stores a model ID, not a free-form message.
241            _ => {}
242        }
243        self
244    }
245
246    /// Create an LLM error with no semantic kind (falls back to string
247    /// classification downstream).
248    pub fn llm(msg: impl Into<String>) -> Self {
249        AgentLoopError::Llm(LlmError {
250            kind: LlmErrorKind::Other,
251            message: msg.into(),
252            retry_attempts: 0,
253            retry_wait_ms: 0,
254            retry_handled: false,
255        })
256    }
257
258    /// Create an LLM error with a semantic kind assigned at the driver boundary.
259    pub fn llm_kind(kind: LlmErrorKind, msg: impl Into<String>) -> Self {
260        AgentLoopError::Llm(LlmError {
261            kind,
262            message: msg.into(),
263            retry_attempts: 0,
264            retry_wait_ms: 0,
265            retry_handled: false,
266        })
267    }
268
269    /// Attach retries already consumed by a lower provider layer. The reason
270    /// loop uses this to avoid multiplying attempt budgets across layers.
271    pub fn with_retry_metadata(mut self, metadata: &crate::llm_retry::RetryMetadata) -> Self {
272        if let AgentLoopError::Llm(error) = &mut self {
273            error.retry_attempts = metadata.attempts;
274            error.retry_wait_ms = metadata.total_retry_wait.as_millis() as u64;
275            error.retry_handled = true;
276        }
277        self
278    }
279
280    /// Number of lower-layer retries already consumed by this failure.
281    pub fn llm_retry_attempts(&self) -> u32 {
282        match self {
283            AgentLoopError::Llm(error) => error.retry_attempts,
284            _ => 0,
285        }
286    }
287
288    /// Whether a lower provider layer already exhausted or rejected recovery.
289    pub fn llm_retry_handled(&self) -> bool {
290        matches!(self, AgentLoopError::Llm(error) if error.retry_handled)
291    }
292
293    /// Get the semantic LLM error kind, if this is an LLM error.
294    pub fn llm_error_kind(&self) -> Option<LlmErrorKind> {
295        match self {
296            AgentLoopError::Llm(err) => Some(err.kind),
297            _ => None,
298        }
299    }
300
301    /// Create a tool execution error
302    pub fn tool(msg: impl Into<String>) -> Self {
303        AgentLoopError::ToolExecution(msg.into())
304    }
305
306    /// Create a message store error
307    pub fn store(msg: impl Into<String>) -> Self {
308        AgentLoopError::MessageStore(msg.into())
309    }
310
311    /// Create an event emission error
312    pub fn event(msg: impl Into<String>) -> Self {
313        AgentLoopError::EventEmission(msg.into())
314    }
315
316    /// Create a configuration error
317    pub fn config(msg: impl Into<String>) -> Self {
318        AgentLoopError::Configuration(msg.into())
319    }
320
321    /// Create an agent not found error
322    pub fn agent_not_found(agent_id: AgentId) -> Self {
323        AgentLoopError::AgentNotFound(agent_id)
324    }
325
326    /// Create a harness not found error
327    pub fn harness_not_found(harness_id: HarnessId) -> Self {
328        AgentLoopError::HarnessNotFound(harness_id)
329    }
330
331    /// Create a session not found error
332    pub fn session_not_found(session_id: SessionId) -> Self {
333        AgentLoopError::SessionNotFound(session_id)
334    }
335
336    /// Create a driver not registered error
337    pub fn driver_not_registered(provider_type: impl Into<String>) -> Self {
338        AgentLoopError::DriverNotRegistered(provider_type.into())
339    }
340
341    /// Create a request too large error
342    pub fn request_too_large(msg: impl Into<String>) -> Self {
343        AgentLoopError::RequestTooLarge(msg.into())
344    }
345
346    /// Create a model not available error
347    pub fn model_not_available(model_id: impl Into<String>) -> Self {
348        AgentLoopError::ModelNotAvailable(model_id.into())
349    }
350
351    /// Create a missing-model configuration error.
352    pub fn model_not_configured() -> Self {
353        AgentLoopError::ModelNotConfigured
354    }
355
356    /// Check if this is a request-too-large error
357    pub fn is_request_too_large(&self) -> bool {
358        matches!(self, AgentLoopError::RequestTooLarge(_))
359    }
360
361    /// Check if this is a model-not-available error
362    pub fn is_model_not_available(&self) -> bool {
363        matches!(self, AgentLoopError::ModelNotAvailable(_))
364    }
365
366    /// Get the model ID if this is a model-not-available error
367    pub fn model_not_available_id(&self) -> Option<&str> {
368        match self {
369            AgentLoopError::ModelNotAvailable(id) => Some(id),
370            _ => None,
371        }
372    }
373
374    /// Check if this is a rate-limit error (semantic kind, or HTTP 429 /
375    /// rate-limit keywords for untyped errors)
376    pub fn is_rate_limited(&self) -> bool {
377        match self {
378            AgentLoopError::Llm(err) => match err.kind {
379                LlmErrorKind::RateLimited => true,
380                LlmErrorKind::Other => {
381                    let msg_lower = err.message.to_ascii_lowercase();
382                    msg_lower.contains("(429)")
383                        || msg_lower.contains("rate limit")
384                        || msg_lower.contains("too many requests")
385                }
386                _ => false,
387            },
388            _ => false,
389        }
390    }
391
392    /// Check if this is an authentication/authorization error (HTTP 401/403)
393    pub fn is_auth_error(&self) -> bool {
394        match self {
395            AgentLoopError::Llm(err) => match err.kind {
396                LlmErrorKind::Authentication => true,
397                LlmErrorKind::Other => {
398                    err.message.contains("(401)") || err.message.contains("(403)")
399                }
400                _ => false,
401            },
402            _ => false,
403        }
404    }
405
406    /// Check if this is a server error (HTTP 5xx or transient provider issue)
407    pub fn is_server_error(&self) -> bool {
408        match self {
409            AgentLoopError::Llm(err) => match err.kind {
410                LlmErrorKind::Unavailable => true,
411                LlmErrorKind::Other => {
412                    let msg = &err.message;
413                    msg.contains("(500)")
414                        || msg.contains("(502)")
415                        || msg.contains("(503)")
416                        || msg.contains("(504)")
417                        || msg.contains("(529)")
418                }
419                _ => false,
420            },
421            _ => false,
422        }
423    }
424
425    /// Check whether an LLM failure is safe to retry.
426    ///
427    /// Semantic driver classification is authoritative. Untyped legacy errors
428    /// retain the message-based fallback until all drivers preserve structure.
429    pub fn is_transient_llm_error(&self) -> bool {
430        match self {
431            AgentLoopError::Llm(err) => match err.kind {
432                LlmErrorKind::RateLimited | LlmErrorKind::Unavailable => true,
433                LlmErrorKind::Authentication
434                | LlmErrorKind::QuotaExhausted
435                | LlmErrorKind::AttestationRequired
436                | LlmErrorKind::InvalidRequest => false,
437                LlmErrorKind::Other => crate::llm_retry::is_transient_error_message(&err.message),
438            },
439            _ => false,
440        }
441    }
442
443    /// Check if this error is deterministic and should never be retried.
444    ///
445    /// Non-retryable errors reference data that is permanently gone (e.g. a
446    /// deleted message, a missing agent). Retrying will never succeed and only
447    /// burns attempts while keeping the workflow stuck.
448    ///
449    /// Note: the durable worker currently uses string-matching via
450    /// `is_non_retryable_task_error` because task errors arrive as strings.
451    /// This method provides the typed equivalent for callers that have access
452    /// to a structured `AgentLoopError`.
453    pub fn is_non_retryable(&self) -> bool {
454        match self {
455            // Missing data is permanent — the entity was deleted.
456            AgentLoopError::AgentNotFound(_)
457            | AgentLoopError::HarnessNotFound(_)
458            | AgentLoopError::SessionNotFound(_)
459            | AgentLoopError::NoMessages
460            | AgentLoopError::ModelNotConfigured => true,
461
462            // Config/driver errors won't self-heal within retries.
463            AgentLoopError::Configuration(_) | AgentLoopError::DriverNotRegistered(_) => true,
464
465            // MessageStore "not found" errors (deleted messages).
466            AgentLoopError::MessageStore(msg) => msg.to_ascii_lowercase().contains("not found"),
467
468            // Everything else is potentially transient.
469            _ => false,
470        }
471    }
472
473    /// Get user-facing error message based on error classification
474    pub fn user_facing_message(&self) -> String {
475        self.user_facing_error(UserFacingErrorContext::default())
476            .fallback_message()
477    }
478
479    /// Get structured user-facing error metadata based on error classification.
480    pub fn user_facing_error(&self, context: UserFacingErrorContext) -> UserFacingError {
481        match self {
482            AgentLoopError::ModelNotConfigured => {
483                UserFacingError::new(user_facing_error_codes::MODEL_NOT_CONFIGURED)
484            }
485            AgentLoopError::ModelNotAvailable(model_id) => {
486                UserFacingError::new(user_facing_error_codes::MODEL_UNAVAILABLE)
487                    .with_field("model_id", model_id)
488                    .with_optional_field("provider", context.provider)
489            }
490            AgentLoopError::RequestTooLarge(_) => {
491                UserFacingError::new(user_facing_error_codes::REQUEST_TOO_LARGE)
492                    .with_optional_field("provider", context.provider)
493                    .with_optional_field("model_id", context.model_id)
494            }
495            AgentLoopError::MaxIterationsReached(max_iterations) => {
496                UserFacingError::new(user_facing_error_codes::MAX_ITERATIONS)
497                    .with_field("max_iterations", max_iterations)
498            }
499            AgentLoopError::Llm(err) => {
500                // Prefer the semantic kind the driver assigned at the provider
501                // boundary; fall back to string classification for untyped
502                // errors so legacy paths keep working.
503                let code = match err.kind {
504                    LlmErrorKind::Authentication => {
505                        Some(user_facing_error_codes::PROVIDER_MISCONFIGURED)
506                    }
507                    LlmErrorKind::QuotaExhausted => {
508                        Some(user_facing_error_codes::PROVIDER_QUOTA_EXHAUSTED)
509                    }
510                    LlmErrorKind::RateLimited => {
511                        Some(user_facing_error_codes::PROVIDER_RATE_LIMITED)
512                    }
513                    LlmErrorKind::Unavailable => {
514                        Some(user_facing_error_codes::PROVIDER_UNAVAILABLE)
515                    }
516                    LlmErrorKind::AttestationRequired => {
517                        Some(user_facing_error_codes::PROVIDER_ATTESTATION_REQUIRED)
518                    }
519                    LlmErrorKind::InvalidRequest | LlmErrorKind::Other => None,
520                };
521                match code {
522                    Some(code) => {
523                        let error = UserFacingError::new(code)
524                            .with_optional_field("provider", context.provider)
525                            .with_optional_field("model_id", context.model_id);
526                        if code == user_facing_error_codes::PROVIDER_RATE_LIMITED {
527                            error.with_optional_field("retry_after", context.retry_after)
528                        } else if code == user_facing_error_codes::PROVIDER_ATTESTATION_REQUIRED {
529                            // `LlmErrorKind` is `Copy` and payload-free, so the
530                            // confirmations and the URL that clears them are
531                            // read back out of the raw body the driver kept in
532                            // `message` rather than carried on the kind.
533                            parse_attestation_requirement(&err.message)
534                                .unwrap_or_else(AttestationRequirement::fallback)
535                                .apply_fields(error)
536                        } else {
537                            error
538                        }
539                    }
540                    None => classify_runtime_error_message(&err.message, &context),
541                }
542            }
543            _ => UserFacingError::new(user_facing_error_codes::PROCESSING_ERROR)
544                .with_optional_field("provider", context.provider)
545                .with_optional_field("model_id", context.model_id),
546        }
547    }
548}
549
550// ============================================================================
551// Store Result Extension Trait
552// ============================================================================
553
554/// Extension trait that converts any `Result<T, E: Display>` into `Result<T, AgentLoopError>`
555/// via `AgentLoopError::store(e.to_string())`.
556///
557/// Replaces the boilerplate pattern:
558/// ```ignore
559/// .map_err(|e| AgentLoopError::store(e.to_string()))?
560/// ```
561/// with:
562/// ```ignore
563/// .store_err()?
564/// ```
565pub trait StoreResultExt<T> {
566    fn store_err(self) -> Result<T>;
567}
568
569impl<T, E: std::fmt::Display> StoreResultExt<T> for std::result::Result<T, E> {
570    fn store_err(self) -> Result<T> {
571        self.map_err(|e| AgentLoopError::store(e.to_string()))
572    }
573}
574
575// ============================================================================
576// SessionFileSystem error classification (EVE-645)
577// ============================================================================
578
579/// Typed classification of a `SessionFileSystem` failure.
580///
581/// The file-system tools (`integrations/filesystem/src/lib.rs`) decide
582/// whether a failure is a *tool error* (surfaced to the agent verbatim — bad
583/// input it can correct) or an *internal error* (logged, generic copy). They
584/// previously made that call with `msg.contains("readonly")` / `"is a
585/// directory"` / `"not found"` style sniffs against the stringified error.
586///
587/// The `SessionFileSystem` trait returns `anyhow::Result<T>` and has 10+
588/// implementors across crates, so widening the trait's error type is out of
589/// scope. Instead, [`classify_fs_error`] gives a single typed seam: it
590/// downcasts to [`FileSystemError`] when an implementor opts in, and otherwise
591/// falls back to the legacy substring heuristics in one place. Implementors can
592/// migrate to returning `FileSystemError` (via `anyhow::Error::new`)
593/// incrementally without changing behavior.
594#[derive(Debug, Clone, Copy, PartialEq, Eq)]
595pub enum FileSystemErrorClass {
596    /// The target (or a path component) does not exist.
597    NotFound,
598    /// The target is read-only and cannot be written or deleted.
599    ReadOnly,
600    /// Expected a file but the path is a directory.
601    IsADirectory,
602    /// Expected a directory but the path is not one.
603    NotADirectory,
604    /// A non-recursive delete refused a non-empty directory.
605    NotEmpty,
606    /// No recognized client-correctable condition; treat as internal.
607    Other,
608}
609
610/// Typed `SessionFileSystem` error. Implementors may return this (wrapped in
611/// `anyhow::Error`) so [`classify_fs_error`] resolves the class without string
612/// matching. Each variant carries the human-facing message so the file tools
613/// can keep surfacing the same text to the agent.
614#[derive(Debug, Error)]
615pub enum FileSystemError {
616    #[error("{0}")]
617    NotFound(String),
618    #[error("{0}")]
619    ReadOnly(String),
620    #[error("{0}")]
621    IsADirectory(String),
622    #[error("{0}")]
623    NotADirectory(String),
624    #[error("{0}")]
625    NotEmpty(String),
626}
627
628impl FileSystemError {
629    fn class(&self) -> FileSystemErrorClass {
630        match self {
631            FileSystemError::NotFound(_) => FileSystemErrorClass::NotFound,
632            FileSystemError::ReadOnly(_) => FileSystemErrorClass::ReadOnly,
633            FileSystemError::IsADirectory(_) => FileSystemErrorClass::IsADirectory,
634            FileSystemError::NotADirectory(_) => FileSystemErrorClass::NotADirectory,
635            FileSystemError::NotEmpty(_) => FileSystemErrorClass::NotEmpty,
636        }
637    }
638}
639
640/// Classify a `SessionFileSystem` failure into a [`FileSystemErrorClass`].
641///
642/// Prefers a typed [`FileSystemError`] in the error chain; falls back to the
643/// legacy substring heuristics (the single remaining place they live) so
644/// untyped implementors keep their current routing. Behavior is identical to
645/// the previous inline `msg.contains(...)` checks in `file_system.rs`:
646/// "readonly" and "is a directory" mark client-correctable write failures,
647/// "not found" / "not a directory" mark client-correctable read failures, and
648/// "not empty" / "recursive" mark client-correctable delete failures.
649pub fn classify_fs_error<E>(err: &E) -> FileSystemErrorClass
650where
651    E: std::error::Error + 'static,
652{
653    // Prefer a typed FileSystemError anywhere in the source chain so an
654    // implementor that opts in is classified without string matching. Works
655    // whether the error is a bare FileSystemError or wrapped (e.g. inside
656    // `AgentLoopError::Internal(anyhow!(FileSystemError::..))`).
657    let mut source: Option<&(dyn std::error::Error + 'static)> = Some(err);
658    while let Some(current) = source {
659        if let Some(typed) = current.downcast_ref::<FileSystemError>() {
660            return typed.class();
661        }
662        source = current.source();
663    }
664
665    let msg = err.to_string();
666    // Note: real-disk backends emit "read-only" (hyphenated); the legacy check
667    // only matched "readonly", so we preserve that exact behavior rather than
668    // silently widening it.
669    if msg.contains("readonly") {
670        FileSystemErrorClass::ReadOnly
671    } else if msg.contains("is a directory") {
672        FileSystemErrorClass::IsADirectory
673    } else if msg.contains("not a directory") {
674        FileSystemErrorClass::NotADirectory
675    } else if msg.contains("not empty") || msg.contains("recursive") {
676        FileSystemErrorClass::NotEmpty
677    } else if msg.contains("not found") {
678        FileSystemErrorClass::NotFound
679    } else {
680        FileSystemErrorClass::Other
681    }
682}
683
684// ============================================================================
685// JSON Helpers
686// ============================================================================
687
688/// Convert a serializable value to `serde_json::Value`, falling back to `Value::Null` on error.
689///
690/// Replaces the boilerplate pattern:
691/// ```ignore
692/// serde_json::to_value(&x).unwrap_or_default()
693/// ```
694pub fn json_val<T: Serialize>(value: &T) -> serde_json::Value {
695    serde_json::to_value(value).unwrap_or_default()
696}
697
698/// Deserialize a `serde_json::Value` into `T`, falling back to `T::default()` on error.
699///
700/// Replaces the boilerplate pattern:
701/// ```ignore
702/// serde_json::from_value(v).unwrap_or_default()
703/// ```
704pub fn from_json<T: DeserializeOwned + Default>(value: serde_json::Value) -> T {
705    serde_json::from_value(value).unwrap_or_default()
706}
707
708#[cfg(test)]
709mod tests {
710    use super::*;
711    use serde_json::json;
712
713    #[test]
714    fn filesystem_typed_errors_win_over_conflicting_messages_and_wrappers() {
715        for (error, expected) in [
716            (
717                FileSystemError::NotFound("readonly".into()),
718                FileSystemErrorClass::NotFound,
719            ),
720            (
721                FileSystemError::ReadOnly("not found".into()),
722                FileSystemErrorClass::ReadOnly,
723            ),
724            (
725                FileSystemError::IsADirectory("not empty".into()),
726                FileSystemErrorClass::IsADirectory,
727            ),
728            (
729                FileSystemError::NotADirectory("is a directory".into()),
730                FileSystemErrorClass::NotADirectory,
731            ),
732            (
733                FileSystemError::NotEmpty("not found".into()),
734                FileSystemErrorClass::NotEmpty,
735            ),
736        ] {
737            assert_eq!(classify_fs_error(&error), expected);
738            let wrapped = AgentLoopError::Internal(
739                anyhow::Error::new(error).context("readonly outer failure"),
740            );
741            assert_eq!(classify_fs_error(&wrapped), expected);
742        }
743    }
744
745    #[test]
746    fn filesystem_legacy_messages_preserve_routing_and_case_boundaries() {
747        for (message, expected) in [
748            (
749                "Cannot modify readonly file: /a",
750                FileSystemErrorClass::ReadOnly,
751            ),
752            (
753                "Cannot delete readonly file: /a",
754                FileSystemErrorClass::ReadOnly,
755            ),
756            (
757                "write target is a directory: /a",
758                FileSystemErrorClass::IsADirectory,
759            ),
760            (
761                "Path is not a directory: /a",
762                FileSystemErrorClass::NotADirectory,
763            ),
764            (
765                "workspace root is not a directory: /a",
766                FileSystemErrorClass::NotADirectory,
767            ),
768            ("Directory not found: /a", FileSystemErrorClass::NotFound),
769            (
770                "Directory is not empty. Use recursive=true to delete",
771                FileSystemErrorClass::NotEmpty,
772            ),
773            (
774                "Cannot delete root directory without recursive flag",
775                FileSystemErrorClass::NotEmpty,
776            ),
777            (
778                "recursive delete failed for /a: io",
779                FileSystemErrorClass::NotEmpty,
780            ),
781            ("readonly file not found", FileSystemErrorClass::ReadOnly),
782            ("file is read-only: /a", FileSystemErrorClass::Other),
783            ("NOT FOUND", FileSystemErrorClass::Other),
784            ("disk full", FileSystemErrorClass::Other),
785        ] {
786            assert_eq!(
787                classify_fs_error(&AgentLoopError::store(message)),
788                expected,
789                "{message}"
790            );
791        }
792    }
793
794    #[test]
795    fn typed_request_and_model_errors_preserve_identity_and_safe_user_payload() {
796        let context = || {
797            UserFacingErrorContext::default()
798                .with_provider("provider")
799                .with_model_id("context-model")
800                .with_retry_after(9)
801        };
802        let request = AgentLoopError::request_too_large("private payload");
803        assert_eq!(request.to_string(), "Request too large: private payload");
804        assert!(request.is_request_too_large());
805        assert!(!request.is_model_not_available());
806        assert_eq!(request.model_not_available_id(), None);
807        assert_eq!(
808            serde_json::to_value(request.user_facing_error(context())).unwrap(),
809            json!({"code":"request_too_large","fields":{"provider":"provider","model_id":"context-model"}})
810        );
811        assert_eq!(
812            request.user_facing_message(),
813            "The conversation has become too long for the model to process. Please start a new session or reduce the context size."
814        );
815        let model = AgentLoopError::model_not_available("gpt-99")
816            .with_provider("custom")
817            .with_provider("custom");
818        assert!(!model.is_request_too_large());
819        assert!(model.is_model_not_available());
820        assert_eq!(model.model_not_available_id(), Some("gpt-99"));
821        assert_eq!(model.to_string(), "Model not available: gpt-99");
822        assert_eq!(
823            model.user_facing_message(),
824            "The model `gpt-99` is not available. It may have been removed, renamed, or your API key may not have access to it. Please select a different model."
825        );
826        assert_eq!(
827            serde_json::to_value(model.user_facing_error(context())).unwrap(),
828            json!({"code":"model_unavailable","fields":{"provider":"provider","model_id":"gpt-99"}})
829        );
830        for other in [
831            AgentLoopError::llm("Request too large: Model not available: gpt-99"),
832            AgentLoopError::tool("failed"),
833            AgentLoopError::Cancelled,
834        ] {
835            assert!(!other.is_request_too_large());
836            assert!(!other.is_model_not_available());
837            assert_eq!(other.model_not_available_id(), None);
838        }
839    }
840
841    #[test]
842    fn semantic_kinds_override_conflicting_text_for_predicates_and_payloads() {
843        for (kind, message, predicates, code) in [
844            (
845                LlmErrorKind::Authentication,
846                "(429) rate limit (503)",
847                (false, true, false, false),
848                "provider_misconfigured",
849            ),
850            (
851                LlmErrorKind::QuotaExhausted,
852                "(401) (429) rate limit (503)",
853                (false, false, false, false),
854                "provider_quota_exhausted",
855            ),
856            (
857                LlmErrorKind::RateLimited,
858                "(401) (503) insufficient_quota",
859                (true, false, false, true),
860                "provider_rate_limited",
861            ),
862            (
863                LlmErrorKind::Unavailable,
864                "(401) (429) insufficient_quota",
865                (false, false, true, true),
866                "provider_unavailable",
867            ),
868            (
869                LlmErrorKind::InvalidRequest,
870                "opaque private failure",
871                (false, false, false, false),
872                "processing_error",
873            ),
874        ] {
875            let error = AgentLoopError::llm_kind(kind, message);
876            assert_eq!(error.llm_error_kind(), Some(kind));
877            assert_eq!(
878                (
879                    error.is_rate_limited(),
880                    error.is_auth_error(),
881                    error.is_server_error(),
882                    error.is_transient_llm_error()
883                ),
884                predicates,
885                "{kind:?}"
886            );
887            let mut fields = json!({"provider":"provider","model_id":"model"});
888            if kind == LlmErrorKind::RateLimited {
889                fields["retry_after"] = json!(12);
890            }
891            assert_eq!(
892                serde_json::to_value(
893                    error.user_facing_error(
894                        UserFacingErrorContext::default()
895                            .with_provider("provider")
896                            .with_model_id("model")
897                            .with_retry_after(12)
898                    )
899                )
900                .unwrap(),
901                json!({"code":code,"fields":fields})
902            );
903        }
904    }
905
906    #[test]
907    fn legacy_predicates_and_user_copy_use_independent_literal_cases() {
908        for (message, expected, copy) in [
909            (
910                "Anthropic API error (429): rate limit exceeded",
911                (true, false, false),
912                "Rate limited by the AI provider. Please wait a moment.",
913            ),
914            (
915                "Rate limit exceeded (after 2 retries)",
916                (true, false, false),
917                "Rate limited by the AI provider. Please wait a moment.",
918            ),
919            (
920                "too many requests",
921                (true, false, false),
922                "Rate limited by the AI provider. Please wait a moment.",
923            ),
924            (
925                "Anthropic API error (401): invalid api key",
926                (false, true, false),
927                "There is a misconfiguration with the AI provider. Please contact support.",
928            ),
929            (
930                "OpenAI API error (403): forbidden",
931                (false, true, false),
932                "There is a misconfiguration with the AI provider. Please contact support.",
933            ),
934            (
935                "Anthropic API error (500): internal server error",
936                (false, false, true),
937                "The AI provider is experiencing issues. Please try again shortly.",
938            ),
939            (
940                "OpenAI API error (503): service unavailable",
941                (false, false, true),
942                "The AI provider is experiencing issues. Please try again shortly.",
943            ),
944            (
945                "Failed to send request: connection refused",
946                (false, false, false),
947                "I encountered an error while processing your request. Please try again later.",
948            ),
949        ] {
950            let error = AgentLoopError::llm(message);
951            assert_eq!(
952                (
953                    error.is_rate_limited(),
954                    error.is_auth_error(),
955                    error.is_server_error()
956                ),
957                expected,
958                "{message}"
959            );
960            assert_eq!(error.user_facing_message(), copy, "{message}");
961        }
962        for status in [502, 504, 529] {
963            assert!(AgentLoopError::llm(format!("error ({status})")).is_server_error());
964        }
965        let non_llm = AgentLoopError::tool("(401) (429) (503) rate limit");
966        assert_eq!(
967            (
968                non_llm.is_rate_limited(),
969                non_llm.is_auth_error(),
970                non_llm.is_server_error(),
971                non_llm.is_transient_llm_error()
972            ),
973            (false, false, false, false)
974        );
975    }
976
977    #[test]
978    fn provider_status_classification_covers_boundaries_and_quota_precedence() {
979        for (status, expected) in [
980            (200, LlmErrorKind::Other),
981            (399, LlmErrorKind::Other),
982            (400, LlmErrorKind::InvalidRequest),
983            (401, LlmErrorKind::Authentication),
984            (403, LlmErrorKind::Authentication),
985            (404, LlmErrorKind::InvalidRequest),
986            (408, LlmErrorKind::Unavailable),
987            (409, LlmErrorKind::Unavailable),
988            (429, LlmErrorKind::RateLimited),
989            (499, LlmErrorKind::InvalidRequest),
990            (500, LlmErrorKind::Unavailable),
991            (501, LlmErrorKind::Other),
992            (502, LlmErrorKind::Unavailable),
993            (503, LlmErrorKind::Unavailable),
994            (529, LlmErrorKind::Unavailable),
995            (599, LlmErrorKind::Unavailable),
996            (600, LlmErrorKind::Other),
997        ] {
998            assert_eq!(
999                LlmErrorKind::from_provider_status(status, "opaque"),
1000                expected,
1001                "{status}"
1002            );
1003        }
1004        for message in [
1005            r#"{"error":{"type":"insufficient_quota"}}"#,
1006            r#"{"error":{"code":"credit_balance_exhausted"}}"#,
1007            r#"{"error":{"type":"usage_limit_reached"}}"#,
1008            "Your credit balance is too low to access the Anthropic API.",
1009        ] {
1010            for status in [400, 401, 429, 503] {
1011                assert_eq!(
1012                    LlmErrorKind::from_provider_status(status, message),
1013                    LlmErrorKind::QuotaExhausted,
1014                    "{status}: {message}"
1015                );
1016            }
1017        }
1018    }
1019
1020    /// The canonical OpenRouter refusal from EVE-952, verbatim off the wire.
1021    const ATTESTATION_BODY: &str = r#"{"error":{"message":"This model requires you to complete the following before use: 18+ age confirmation. Confirm at https://openrouter.ai/settings/preferences.","code":403,"metadata":{"missing_attestation_types":["age_18plus"],"routing_funnel":[{"step":"Initial Endpoints","endpoint_count":1}],"failed_routing_step":"Gate Endpoints with Attestations"}}}"#;
1022
1023    #[test]
1024    fn attestation_gate_is_classified_apart_from_other_403s() {
1025        assert_eq!(
1026            LlmErrorKind::from_provider_status(403, ATTESTATION_BODY),
1027            LlmErrorKind::AttestationRequired
1028        );
1029        // Status is not the signal: the same body under another status still
1030        // names the gate, and a 403 without one stays an auth failure.
1031        assert_eq!(
1032            LlmErrorKind::from_provider_status(429, ATTESTATION_BODY),
1033            LlmErrorKind::AttestationRequired
1034        );
1035        for body in [
1036            r#"{"error":{"message":"Invalid credentials","code":403}}"#,
1037            r#"{"error":{"message":"Insufficient credits","code":403,"metadata":{"routing_funnel":[]}}}"#,
1038            "opaque",
1039        ] {
1040            assert_ne!(
1041                LlmErrorKind::from_provider_status(403, body),
1042                LlmErrorKind::AttestationRequired,
1043                "{body}"
1044            );
1045        }
1046        // Exhausted billing keeps precedence over the gate check.
1047        assert_eq!(
1048            LlmErrorKind::from_provider_status(
1049                403,
1050                r#"{"error":{"message":"insufficient_quota; requires you to complete the following before use"}}"#
1051            ),
1052            LlmErrorKind::QuotaExhausted
1053        );
1054    }
1055
1056    #[test]
1057    fn attestation_gate_reaches_the_reader_with_the_types_and_the_confirm_url() {
1058        let error = AgentLoopError::llm_kind(
1059            LlmErrorKind::AttestationRequired,
1060            format!("OpenAI Responses API error (403): {ATTESTATION_BODY}"),
1061        )
1062        .with_provider("openrouter");
1063        // Not a credential problem and never worth retrying.
1064        assert!(!error.is_auth_error());
1065        assert!(!error.is_transient_llm_error());
1066        assert_eq!(
1067            serde_json::to_value(
1068                error.user_facing_error(
1069                    UserFacingErrorContext::default()
1070                        .with_provider("openrouter")
1071                        .with_model_id("meta/muse-spark-1.3-contributor")
1072                )
1073            )
1074            .unwrap(),
1075            json!({
1076                "code": "provider_attestation_required",
1077                "fields": {
1078                    "provider": "openrouter",
1079                    "model_id": "meta/muse-spark-1.3-contributor",
1080                    "missing_types": ["age_18plus"],
1081                    "confirm_url": "https://openrouter.ai/settings/preferences",
1082                }
1083            })
1084        );
1085        assert_eq!(
1086            error.user_facing_message(),
1087            "The AI provider account has not completed a confirmation this model requires (age_18plus). Complete it at https://openrouter.ai/settings/preferences, then try again."
1088        );
1089    }
1090
1091    #[test]
1092    fn untyped_attestation_bodies_still_route_off_the_403_misconfiguration_copy() {
1093        // Legacy/untyped errors reach the string classifier instead; it must
1094        // reach the same code rather than "contact support".
1095        let error = AgentLoopError::llm(format!(
1096            "provider 'openrouter': OpenAI Responses API error (403): {ATTESTATION_BODY}"
1097        ));
1098        assert_eq!(
1099            error
1100                .user_facing_error(UserFacingErrorContext::default())
1101                .code,
1102            "provider_attestation_required"
1103        );
1104    }
1105
1106    #[test]
1107    fn attestation_parsing_covers_multiple_types_escaped_bodies_and_a_missing_url() {
1108        let requirement = |body: &str| {
1109            parse_attestation_requirement(body).unwrap_or_else(|| panic!("no gate in {body}"))
1110        };
1111
1112        // Multiple gates, in payload order.
1113        let multiple = requirement(
1114            r#"{"error":{"message":"This model requires you to complete the following before use: 18+ age confirmation and identity verification. Confirm at https://openrouter.ai/settings/preferences.","metadata":{"missing_attestation_types":["age_18plus","identity_verified"]}}}"#,
1115        );
1116        assert_eq!(multiple.missing_types, ["age_18plus", "identity_verified"]);
1117        assert_eq!(
1118            multiple.confirm_url,
1119            "https://openrouter.ai/settings/preferences"
1120        );
1121
1122        // JSON-escaped body (a provider error nested in another envelope).
1123        let escaped = requirement(
1124            r#"{"detail":"{\"error\":{\"message\":\"This model requires you to complete the following before use: 18+ age confirmation. Confirm at https:\/\/openrouter.ai\/settings\/gates.\",\"metadata\":{\"missing_attestation_types\":[\"age_18plus\"]}}}"}"#,
1125        );
1126        assert_eq!(escaped.missing_types, ["age_18plus"]);
1127        assert_eq!(escaped.confirm_url, "https://openrouter.ai/settings/gates");
1128
1129        // No URL in the message: fall back rather than leave the reader with
1130        // nowhere to go.
1131        let no_url = requirement(
1132            r#"{"error":{"message":"This model requires you to complete the following before use: 18+ age confirmation.","metadata":{"missing_attestation_types":["age_18plus"]}}}"#,
1133        );
1134        assert_eq!(
1135            no_url.confirm_url,
1136            "https://openrouter.ai/settings/preferences"
1137        );
1138
1139        // The gate sentence alone is enough; the metadata block is optional.
1140        let sentence_only = requirement(
1141            "This model requires you to complete the following before use: 18+ age confirmation. Confirm at https://openrouter.ai/settings/preferences",
1142        );
1143        assert!(sentence_only.missing_types.is_empty());
1144        assert_eq!(
1145            sentence_only.confirm_url,
1146            "https://openrouter.ai/settings/preferences"
1147        );
1148        // With nothing parsed, the message drops the list rather than
1149        // rendering an empty one.
1150        assert_eq!(
1151            AgentLoopError::llm_kind(
1152                LlmErrorKind::AttestationRequired,
1153                "This model requires you to complete the following before use: a confirmation."
1154            )
1155            .user_facing_message(),
1156            "The AI provider account has not completed a confirmation this model requires. Complete it at https://openrouter.ai/settings/preferences, then try again."
1157        );
1158
1159        // A URL in the driver's own prefix is not mistaken for the gate page.
1160        assert_eq!(
1161            requirement(&format!(
1162                "POST https://openrouter.ai/api/v1/responses failed: {ATTESTATION_BODY}"
1163            ))
1164            .confirm_url,
1165            "https://openrouter.ai/settings/preferences"
1166        );
1167
1168        for body in [
1169            r#"{"error":{"message":"Invalid credentials"}}"#,
1170            r#"{"error":{"metadata":{"missing_attestation_types":[]}}}"#,
1171            "",
1172        ] {
1173            assert!(parse_attestation_requirement(body).is_none(), "{body}");
1174        }
1175    }
1176
1177    #[test]
1178    fn a_hostile_attestation_payload_cannot_choose_how_much_reaches_the_viewer() {
1179        let types = (0..40)
1180            .map(|index| format!(r#""gate_{index}""#))
1181            .collect::<Vec<_>>()
1182            .join(",");
1183        let long_type = "x".repeat(65);
1184        let long_url = format!("https://evil.example/{}", "a".repeat(400));
1185        let requirement = parse_attestation_requirement(&format!(
1186            r#"{{"error":{{"message":"This model requires you to complete the following before use: gates. Confirm at {long_url}","metadata":{{"missing_attestation_types":["{long_type}",{types}]}}}}}}"#
1187        ))
1188        .expect("gate recognized");
1189
1190        // Over-long entries are dropped, not truncated, and the list is capped.
1191        assert_eq!(requirement.missing_types.len(), 8);
1192        assert_eq!(requirement.missing_types[0], "gate_0");
1193        // An over-long URL falls back rather than shipping a 400-char link.
1194        assert_eq!(
1195            requirement.confirm_url,
1196            "https://openrouter.ai/settings/preferences"
1197        );
1198
1199        // Non-http(s) schemes never become the confirmation link.
1200        for scheme in [
1201            "javascript:alert(1)",
1202            "data:text/html,<script>",
1203            "file:///etc/passwd",
1204        ] {
1205            assert_eq!(
1206                parse_attestation_requirement(&format!(
1207                    "This model requires you to complete the following before use: a gate. Confirm at {scheme}"
1208                ))
1209                .expect("gate recognized")
1210                .confirm_url,
1211                "https://openrouter.ai/settings/preferences",
1212                "{scheme}"
1213            );
1214        }
1215    }
1216
1217    #[test]
1218    fn provider_text_classification_uses_independent_keywords_and_precedence() {
1219        for (message, expected) in [
1220            ("ThrottlingException", LlmErrorKind::RateLimited),
1221            ("TooManyRequestsException", LlmErrorKind::RateLimited),
1222            ("RATE LIMIT", LlmErrorKind::RateLimited),
1223            ("too many requests", LlmErrorKind::RateLimited),
1224            ("AccessDeniedException", LlmErrorKind::Authentication),
1225            ("UnrecognizedClientException", LlmErrorKind::Authentication),
1226            ("ExpiredTokenException", LlmErrorKind::Authentication),
1227            ("InvalidSignatureException", LlmErrorKind::Authentication),
1228            ("unauthorized", LlmErrorKind::Authentication),
1229            ("ServiceUnavailableException", LlmErrorKind::Unavailable),
1230            ("service unavailable", LlmErrorKind::Unavailable),
1231            ("InternalServerException", LlmErrorKind::Unavailable),
1232            ("ModelNotReadyException", LlmErrorKind::Unavailable),
1233            (
1234                "usage_limit_reached; resets_at=1783767823; throttlingexception",
1235                LlmErrorKind::QuotaExhausted,
1236            ),
1237            ("something else entirely", LlmErrorKind::Other),
1238        ] {
1239            assert_eq!(
1240                LlmErrorKind::from_error_text(message),
1241                expected,
1242                "{message}"
1243            );
1244        }
1245    }
1246
1247    #[test]
1248    fn provider_prefix_preserves_kind_and_retry_metadata_without_duplication() {
1249        let metadata = crate::llm_retry::RetryMetadata {
1250            attempts: 2,
1251            total_retry_wait: std::time::Duration::from_millis(1234),
1252            ..Default::default()
1253        };
1254        let error = AgentLoopError::llm_kind(LlmErrorKind::Unavailable, "network failure")
1255            .with_retry_metadata(&metadata)
1256            .with_provider("custom")
1257            .with_provider("custom");
1258        assert_eq!(error.llm_retry_attempts(), 2);
1259        assert!(error.llm_retry_handled());
1260        let AgentLoopError::Llm(error) = error else {
1261            panic!("lost LLM variant")
1262        };
1263        assert_eq!(
1264            serde_json::to_value(error).unwrap(),
1265            json!({"kind":"unavailable","message":"provider 'custom': network failure","retry_attempts":2,"retry_wait_ms":1234,"retry_handled":true})
1266        );
1267        let legacy: LlmError =
1268            serde_json::from_value(json!({"kind":"other","message":"legacy"})).unwrap();
1269        assert_eq!(
1270            serde_json::to_value(legacy).unwrap(),
1271            json!({"kind":"other","message":"legacy","retry_attempts":0,"retry_wait_ms":0,"retry_handled":false})
1272        );
1273        let non_llm = AgentLoopError::Cancelled
1274            .with_retry_metadata(&metadata)
1275            .with_provider("custom");
1276        assert!(matches!(non_llm, AgentLoopError::Cancelled));
1277        assert_eq!(non_llm.llm_retry_attempts(), 0);
1278        assert!(!non_llm.llm_retry_handled());
1279    }
1280
1281    #[test]
1282    fn missing_model_and_iteration_limits_have_complete_safe_payloads() {
1283        let missing = AgentLoopError::model_not_configured();
1284        assert!(missing.is_non_retryable());
1285        assert_eq!(
1286            missing.user_facing_message(),
1287            "No model is configured for this chat. Choose a model or configure a default model, then try again."
1288        );
1289        assert_eq!(
1290            serde_json::to_value(missing.user_facing_error(UserFacingErrorContext::default()))
1291                .unwrap(),
1292            json!({"code":"model_not_configured"})
1293        );
1294        assert_eq!(
1295            serde_json::to_value(
1296                AgentLoopError::MaxIterationsReached(7)
1297                    .user_facing_error(UserFacingErrorContext::default())
1298            )
1299            .unwrap(),
1300            json!({"code":"max_iterations","fields":{"max_iterations":7}})
1301        );
1302    }
1303
1304    #[test]
1305    fn store_adapter_preserves_success_and_exact_error_variant_and_message() {
1306        let success: std::result::Result<Vec<String>, String> =
1307            Ok(vec!["first".into(), "second".into()]);
1308        assert_eq!(success.store_err().unwrap(), ["first", "second"]);
1309        let failure: std::result::Result<(), std::io::Error> =
1310            Err(std::io::Error::other("db unavailable"));
1311        let error = failure.store_err().unwrap_err();
1312        assert_eq!(error.to_string(), "Message store error: db unavailable");
1313        assert!(matches!(error,AgentLoopError::MessageStore(message) if message=="db unavailable"));
1314    }
1315
1316    #[test]
1317    fn json_helpers_preserve_structures_and_apply_documented_error_defaults() {
1318        assert_eq!(json_val(&vec![1, 2, 3]), json!([1, 2, 3]));
1319        assert_eq!(from_json::<Vec<String>>(json!(["a", "b"])), ["a", "b"]);
1320        assert_eq!(from_json::<i32>(json!("not a number")), 0);
1321        struct Fails;
1322        impl Serialize for Fails {
1323            fn serialize<S: serde::Serializer>(
1324                &self,
1325                _: S,
1326            ) -> std::result::Result<S::Ok, S::Error> {
1327                Err(serde::ser::Error::custom("synthetic serialization failure"))
1328            }
1329        }
1330        assert_eq!(json_val(&Fails), serde_json::Value::Null);
1331    }
1332}