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