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