Skip to main content

everruns_core/
error.rs

1// Error types for the agent loop
2//
3// StoreResultExt: extension trait to replace repeated .map_err(|e| AgentLoopError::store(...))? patterns
4// json_val / from_json: helpers to replace repeated serde_json::to_value/from_value boilerplate
5
6use crate::typed_id::{AgentId, HarnessId, SessionId};
7use crate::user_facing_error::{
8    UserFacingError, UserFacingErrorContext, classify_runtime_error_message,
9    codes as user_facing_error_codes, is_provider_quota_message,
10};
11use serde::{Deserialize, Serialize, de::DeserializeOwned};
12use thiserror::Error;
13
14/// Result type alias for agent loop operations
15pub type Result<T> = std::result::Result<T, AgentLoopError>;
16
17/// Semantic classification of an LLM provider error, assigned by the driver
18/// at the provider boundary where the HTTP status and response body are still
19/// available. Downstream consumers prefer this over re-parsing error strings;
20/// `LlmErrorKind::Other` falls back to string classification
21/// (`classify_runtime_error_message`) so untyped errors keep working.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum LlmErrorKind {
25    /// Invalid or missing credentials, or access denied (401/403, bad API key).
26    Authentication,
27    /// Provider account is out of credits/quota (billing). Non-transient:
28    /// needs operator action, unlike a regular rate limit.
29    QuotaExhausted,
30    /// Transient rate limit (429).
31    RateLimited,
32    /// Provider outage or unreachable (5xx, 529, network failure).
33    Unavailable,
34    /// Provider rejected the request shape (4xx that is not auth/quota/429).
35    InvalidRequest,
36    /// Unclassified; downstream falls back to string classification.
37    Other,
38}
39
40impl LlmErrorKind {
41    /// Classify a provider HTTP error from status code + response body.
42    ///
43    /// Quota/billing patterns are checked before the status code because
44    /// providers surface exhausted billing under different statuses
45    /// (OpenAI: 429 `insufficient_quota`, Anthropic: 400 "credit balance is
46    /// too low").
47    pub fn from_provider_status(status: u16, body: &str) -> Self {
48        if is_provider_quota_message(body) {
49            return LlmErrorKind::QuotaExhausted;
50        }
51        match status {
52            401 | 403 => LlmErrorKind::Authentication,
53            429 => LlmErrorKind::RateLimited,
54            408 | 500..=599 => LlmErrorKind::Unavailable,
55            400..=499 => LlmErrorKind::InvalidRequest,
56            _ => LlmErrorKind::Other,
57        }
58    }
59
60    /// Keyword-based classification for drivers without an HTTP status at the
61    /// error site (e.g. Bedrock SDK errors).
62    pub fn from_error_text(text: &str) -> Self {
63        if is_provider_quota_message(text) {
64            return LlmErrorKind::QuotaExhausted;
65        }
66        let lower = text.to_ascii_lowercase();
67        if lower.contains("throttlingexception")
68            || lower.contains("toomanyrequestsexception")
69            || lower.contains("rate limit")
70            || lower.contains("too many requests")
71        {
72            return LlmErrorKind::RateLimited;
73        }
74        if lower.contains("accessdeniedexception")
75            || lower.contains("unrecognizedclientexception")
76            || lower.contains("expiredtokenexception")
77            || lower.contains("invalidsignatureexception")
78            || lower.contains("unauthorized")
79        {
80            return LlmErrorKind::Authentication;
81        }
82        if lower.contains("serviceunavailable")
83            || lower.contains("service unavailable")
84            || lower.contains("internalserverexception")
85            || lower.contains("modelnotreadyexception")
86        {
87            return LlmErrorKind::Unavailable;
88        }
89        LlmErrorKind::Other
90    }
91}
92
93/// LLM provider error with a semantic kind attached by the driver.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct LlmError {
96    pub kind: LlmErrorKind,
97    pub message: String,
98}
99
100impl std::fmt::Display for LlmError {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        f.write_str(&self.message)
103    }
104}
105
106/// Errors that can occur during agent loop execution
107#[derive(Debug, Error)]
108pub enum AgentLoopError {
109    /// LLM provider error
110    #[error("LLM error: {0}")]
111    Llm(LlmError),
112
113    /// Request too large error (context length exceeded, token limits, etc.)
114    /// Contains the original error message for logging
115    #[error("Request too large: {0}")]
116    RequestTooLarge(String),
117
118    /// Model not available (404, model not found, access denied for model)
119    /// Contains the model_id string that was requested
120    #[error("Model not available: {0}")]
121    ModelNotAvailable(String),
122
123    /// Tool execution error
124    #[error("Tool execution error: {0}")]
125    ToolExecution(String),
126
127    /// Message store error
128    #[error("Message store error: {0}")]
129    MessageStore(String),
130
131    /// Event emission error
132    #[error("Event emission error: {0}")]
133    EventEmission(String),
134
135    /// Configuration error
136    #[error("Configuration error: {0}")]
137    Configuration(String),
138
139    /// Loop terminated due to max iterations
140    #[error("Max iterations ({0}) reached")]
141    MaxIterationsReached(usize),
142
143    /// Loop was cancelled
144    #[error("Loop cancelled")]
145    Cancelled,
146
147    /// No messages to process
148    #[error("No messages to process")]
149    NoMessages,
150
151    /// Agent not found
152    #[error("Agent not found: {0}")]
153    AgentNotFound(AgentId),
154
155    /// Harness not found
156    #[error("Harness not found: {0}")]
157    HarnessNotFound(HarnessId),
158
159    /// Session not found
160    #[error("Session not found: {0}")]
161    SessionNotFound(SessionId),
162
163    /// Internal error
164    #[error("Internal error: {0}")]
165    Internal(#[from] anyhow::Error),
166
167    /// Driver not registered for provider type
168    #[error(
169        "No driver registered for provider type '{0}'. Make sure the driver is registered at startup."
170    )]
171    DriverNotRegistered(String),
172}
173
174impl AgentLoopError {
175    /// Create an LLM error with no semantic kind (falls back to string
176    /// classification downstream).
177    pub fn llm(msg: impl Into<String>) -> Self {
178        AgentLoopError::Llm(LlmError {
179            kind: LlmErrorKind::Other,
180            message: msg.into(),
181        })
182    }
183
184    /// Create an LLM error with a semantic kind assigned at the driver boundary.
185    pub fn llm_kind(kind: LlmErrorKind, msg: impl Into<String>) -> Self {
186        AgentLoopError::Llm(LlmError {
187            kind,
188            message: msg.into(),
189        })
190    }
191
192    /// Get the semantic LLM error kind, if this is an LLM error.
193    pub fn llm_error_kind(&self) -> Option<LlmErrorKind> {
194        match self {
195            AgentLoopError::Llm(err) => Some(err.kind),
196            _ => None,
197        }
198    }
199
200    /// Create a tool execution error
201    pub fn tool(msg: impl Into<String>) -> Self {
202        AgentLoopError::ToolExecution(msg.into())
203    }
204
205    /// Create a message store error
206    pub fn store(msg: impl Into<String>) -> Self {
207        AgentLoopError::MessageStore(msg.into())
208    }
209
210    /// Create an event emission error
211    pub fn event(msg: impl Into<String>) -> Self {
212        AgentLoopError::EventEmission(msg.into())
213    }
214
215    /// Create a configuration error
216    pub fn config(msg: impl Into<String>) -> Self {
217        AgentLoopError::Configuration(msg.into())
218    }
219
220    /// Create an agent not found error
221    pub fn agent_not_found(agent_id: AgentId) -> Self {
222        AgentLoopError::AgentNotFound(agent_id)
223    }
224
225    /// Create a harness not found error
226    pub fn harness_not_found(harness_id: HarnessId) -> Self {
227        AgentLoopError::HarnessNotFound(harness_id)
228    }
229
230    /// Create a session not found error
231    pub fn session_not_found(session_id: SessionId) -> Self {
232        AgentLoopError::SessionNotFound(session_id)
233    }
234
235    /// Create a driver not registered error
236    pub fn driver_not_registered(provider_type: impl Into<String>) -> Self {
237        AgentLoopError::DriverNotRegistered(provider_type.into())
238    }
239
240    /// Create a request too large error
241    pub fn request_too_large(msg: impl Into<String>) -> Self {
242        AgentLoopError::RequestTooLarge(msg.into())
243    }
244
245    /// Create a model not available error
246    pub fn model_not_available(model_id: impl Into<String>) -> Self {
247        AgentLoopError::ModelNotAvailable(model_id.into())
248    }
249
250    /// Check if this is a request-too-large error
251    pub fn is_request_too_large(&self) -> bool {
252        matches!(self, AgentLoopError::RequestTooLarge(_))
253    }
254
255    /// Check if this is a model-not-available error
256    pub fn is_model_not_available(&self) -> bool {
257        matches!(self, AgentLoopError::ModelNotAvailable(_))
258    }
259
260    /// Get the model ID if this is a model-not-available error
261    pub fn model_not_available_id(&self) -> Option<&str> {
262        match self {
263            AgentLoopError::ModelNotAvailable(id) => Some(id),
264            _ => None,
265        }
266    }
267
268    /// Check if this is a rate-limit error (semantic kind, or HTTP 429 /
269    /// rate-limit keywords for untyped errors)
270    pub fn is_rate_limited(&self) -> bool {
271        match self {
272            AgentLoopError::Llm(err) => match err.kind {
273                LlmErrorKind::RateLimited => true,
274                LlmErrorKind::Other => {
275                    let msg_lower = err.message.to_ascii_lowercase();
276                    msg_lower.contains("(429)")
277                        || msg_lower.contains("rate limit")
278                        || msg_lower.contains("too many requests")
279                }
280                _ => false,
281            },
282            _ => false,
283        }
284    }
285
286    /// Check if this is an authentication/authorization error (HTTP 401/403)
287    pub fn is_auth_error(&self) -> bool {
288        match self {
289            AgentLoopError::Llm(err) => match err.kind {
290                LlmErrorKind::Authentication => true,
291                LlmErrorKind::Other => {
292                    err.message.contains("(401)") || err.message.contains("(403)")
293                }
294                _ => false,
295            },
296            _ => false,
297        }
298    }
299
300    /// Check if this is a server error (HTTP 5xx or transient provider issue)
301    pub fn is_server_error(&self) -> bool {
302        match self {
303            AgentLoopError::Llm(err) => match err.kind {
304                LlmErrorKind::Unavailable => true,
305                LlmErrorKind::Other => {
306                    let msg = &err.message;
307                    msg.contains("(500)")
308                        || msg.contains("(502)")
309                        || msg.contains("(503)")
310                        || msg.contains("(504)")
311                        || msg.contains("(529)")
312                }
313                _ => false,
314            },
315            _ => false,
316        }
317    }
318
319    /// Check if this error is deterministic and should never be retried.
320    ///
321    /// Non-retryable errors reference data that is permanently gone (e.g. a
322    /// deleted message, a missing agent). Retrying will never succeed and only
323    /// burns attempts while keeping the workflow stuck.
324    ///
325    /// Note: the durable worker currently uses string-matching via
326    /// `is_non_retryable_task_error` because task errors arrive as strings.
327    /// This method provides the typed equivalent for callers that have access
328    /// to a structured `AgentLoopError`.
329    pub fn is_non_retryable(&self) -> bool {
330        match self {
331            // Missing data is permanent — the entity was deleted.
332            AgentLoopError::AgentNotFound(_)
333            | AgentLoopError::HarnessNotFound(_)
334            | AgentLoopError::SessionNotFound(_)
335            | AgentLoopError::NoMessages => true,
336
337            // Config/driver errors won't self-heal within retries.
338            AgentLoopError::Configuration(_) | AgentLoopError::DriverNotRegistered(_) => true,
339
340            // MessageStore "not found" errors (deleted messages).
341            AgentLoopError::MessageStore(msg) => msg.to_ascii_lowercase().contains("not found"),
342
343            // Everything else is potentially transient.
344            _ => false,
345        }
346    }
347
348    /// Get user-facing error message based on error classification
349    pub fn user_facing_message(&self) -> String {
350        self.user_facing_error(UserFacingErrorContext::default())
351            .fallback_message()
352    }
353
354    /// Get structured user-facing error metadata based on error classification.
355    pub fn user_facing_error(&self, context: UserFacingErrorContext) -> UserFacingError {
356        match self {
357            AgentLoopError::ModelNotAvailable(model_id) => {
358                UserFacingError::new(user_facing_error_codes::MODEL_UNAVAILABLE)
359                    .with_field("model_id", model_id)
360                    .with_optional_field("provider", context.provider)
361            }
362            AgentLoopError::RequestTooLarge(_) => {
363                UserFacingError::new(user_facing_error_codes::REQUEST_TOO_LARGE)
364                    .with_optional_field("provider", context.provider)
365                    .with_optional_field("model_id", context.model_id)
366            }
367            AgentLoopError::MaxIterationsReached(max_iterations) => {
368                UserFacingError::new(user_facing_error_codes::MAX_ITERATIONS)
369                    .with_field("max_iterations", max_iterations)
370            }
371            AgentLoopError::Llm(err) => {
372                // Prefer the semantic kind the driver assigned at the provider
373                // boundary; fall back to string classification for untyped
374                // errors so legacy paths keep working.
375                let code = match err.kind {
376                    LlmErrorKind::Authentication => {
377                        Some(user_facing_error_codes::PROVIDER_MISCONFIGURED)
378                    }
379                    LlmErrorKind::QuotaExhausted => {
380                        Some(user_facing_error_codes::PROVIDER_QUOTA_EXHAUSTED)
381                    }
382                    LlmErrorKind::RateLimited => {
383                        Some(user_facing_error_codes::PROVIDER_RATE_LIMITED)
384                    }
385                    LlmErrorKind::Unavailable => {
386                        Some(user_facing_error_codes::PROVIDER_UNAVAILABLE)
387                    }
388                    LlmErrorKind::InvalidRequest | LlmErrorKind::Other => None,
389                };
390                match code {
391                    Some(code) => {
392                        let error = UserFacingError::new(code)
393                            .with_optional_field("provider", context.provider)
394                            .with_optional_field("model_id", context.model_id);
395                        if code == user_facing_error_codes::PROVIDER_RATE_LIMITED {
396                            error.with_optional_field("retry_after", context.retry_after)
397                        } else {
398                            error
399                        }
400                    }
401                    None => classify_runtime_error_message(&err.message, &context),
402                }
403            }
404            _ => UserFacingError::new(user_facing_error_codes::PROCESSING_ERROR)
405                .with_optional_field("provider", context.provider)
406                .with_optional_field("model_id", context.model_id),
407        }
408    }
409}
410
411// ============================================================================
412// Store Result Extension Trait
413// ============================================================================
414
415/// Extension trait that converts any `Result<T, E: Display>` into `Result<T, AgentLoopError>`
416/// via `AgentLoopError::store(e.to_string())`.
417///
418/// Replaces the boilerplate pattern:
419/// ```ignore
420/// .map_err(|e| AgentLoopError::store(e.to_string()))?
421/// ```
422/// with:
423/// ```ignore
424/// .store_err()?
425/// ```
426pub trait StoreResultExt<T> {
427    fn store_err(self) -> Result<T>;
428}
429
430impl<T, E: std::fmt::Display> StoreResultExt<T> for std::result::Result<T, E> {
431    fn store_err(self) -> Result<T> {
432        self.map_err(|e| AgentLoopError::store(e.to_string()))
433    }
434}
435
436// ============================================================================
437// JSON Helpers
438// ============================================================================
439
440/// Convert a serializable value to `serde_json::Value`, falling back to `Value::Null` on error.
441///
442/// Replaces the boilerplate pattern:
443/// ```ignore
444/// serde_json::to_value(&x).unwrap_or_default()
445/// ```
446pub fn json_val<T: Serialize>(value: &T) -> serde_json::Value {
447    serde_json::to_value(value).unwrap_or_default()
448}
449
450/// Deserialize a `serde_json::Value` into `T`, falling back to `T::default()` on error.
451///
452/// Replaces the boilerplate pattern:
453/// ```ignore
454/// serde_json::from_value(v).unwrap_or_default()
455/// ```
456pub fn from_json<T: DeserializeOwned + Default>(value: serde_json::Value) -> T {
457    serde_json::from_value(value).unwrap_or_default()
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463
464    #[test]
465    fn test_is_request_too_large_returns_true_for_typed_error() {
466        let err = AgentLoopError::request_too_large("context length exceeded");
467        assert!(err.is_request_too_large());
468    }
469
470    #[test]
471    fn test_is_request_too_large_returns_false_for_llm_error() {
472        let err = AgentLoopError::llm("OpenAI API error (500): Internal server error");
473        assert!(!err.is_request_too_large());
474    }
475
476    #[test]
477    fn test_is_request_too_large_returns_false_for_other_errors() {
478        let err = AgentLoopError::ToolExecution("some error".to_string());
479        assert!(!err.is_request_too_large());
480
481        let err = AgentLoopError::Cancelled;
482        assert!(!err.is_request_too_large());
483    }
484
485    #[test]
486    fn test_request_too_large_error_preserves_message() {
487        let original_msg = "OpenAI API error (429): Request too large for gpt-4";
488        let err = AgentLoopError::request_too_large(original_msg);
489        assert_eq!(
490            err.to_string(),
491            format!("Request too large: {}", original_msg)
492        );
493    }
494
495    #[test]
496    fn test_is_model_not_available_returns_true_for_typed_error() {
497        let err = AgentLoopError::model_not_available("claude-sonnet-4-6-20260217");
498        assert!(err.is_model_not_available());
499        assert_eq!(
500            err.model_not_available_id(),
501            Some("claude-sonnet-4-6-20260217")
502        );
503    }
504
505    #[test]
506    fn test_is_model_not_available_returns_false_for_llm_error() {
507        let err = AgentLoopError::llm("some error");
508        assert!(!err.is_model_not_available());
509        assert_eq!(err.model_not_available_id(), None);
510    }
511
512    #[test]
513    fn test_model_not_available_error_display() {
514        let err = AgentLoopError::model_not_available("gpt-99");
515        assert_eq!(err.to_string(), "Model not available: gpt-99");
516    }
517
518    #[test]
519    fn test_is_rate_limited_detects_429() {
520        let err = AgentLoopError::llm("Anthropic API error (429): rate limit exceeded");
521        assert!(err.is_rate_limited());
522    }
523
524    #[test]
525    fn test_is_rate_limited_detects_rate_limit_keyword() {
526        let err =
527            AgentLoopError::llm("Rate limit exceeded (after 2 retries, last error: too many)");
528        assert!(err.is_rate_limited());
529    }
530
531    #[test]
532    fn test_is_rate_limited_false_for_server_error() {
533        let err = AgentLoopError::llm("Anthropic API error (500): internal server error");
534        assert!(!err.is_rate_limited());
535    }
536
537    #[test]
538    fn test_is_auth_error_detects_401() {
539        let err = AgentLoopError::llm("Anthropic API error (401): invalid api key");
540        assert!(err.is_auth_error());
541    }
542
543    #[test]
544    fn test_is_auth_error_detects_403() {
545        let err = AgentLoopError::llm("OpenAI API error (403): forbidden");
546        assert!(err.is_auth_error());
547    }
548
549    #[test]
550    fn test_is_server_error_detects_500() {
551        let err = AgentLoopError::llm("Anthropic API error (500): internal server error");
552        assert!(err.is_server_error());
553    }
554
555    #[test]
556    fn test_is_server_error_detects_503() {
557        let err = AgentLoopError::llm("OpenAI API error (503): service unavailable");
558        assert!(err.is_server_error());
559    }
560
561    #[test]
562    fn test_user_facing_message_rate_limited() {
563        let err = AgentLoopError::llm("Anthropic API error (429): rate limit exceeded");
564        assert_eq!(
565            err.user_facing_message(),
566            "Rate limited by the AI provider. Please wait a moment."
567        );
568    }
569
570    #[test]
571    fn test_user_facing_message_auth_error() {
572        let err = AgentLoopError::llm("Anthropic API error (401): invalid api key");
573        assert_eq!(
574            err.user_facing_message(),
575            "There is a misconfiguration with the AI provider. Please contact support."
576        );
577    }
578
579    #[test]
580    fn test_user_facing_message_server_error() {
581        let err = AgentLoopError::llm("Anthropic API error (500): internal server error");
582        assert_eq!(
583            err.user_facing_message(),
584            "The AI provider is experiencing issues. Please try again shortly."
585        );
586    }
587
588    #[test]
589    fn test_user_facing_message_generic_fallback() {
590        let err = AgentLoopError::llm("Failed to send request: connection refused");
591        assert_eq!(
592            err.user_facing_message(),
593            "I encountered an error while processing your request. Please try again later."
594        );
595    }
596
597    #[test]
598    fn test_user_facing_message_model_not_available() {
599        let err = AgentLoopError::model_not_available("gpt-99");
600        assert!(err.user_facing_message().contains("gpt-99"));
601        assert!(err.user_facing_message().contains("not available"));
602    }
603
604    #[test]
605    fn test_user_facing_message_request_too_large() {
606        let err = AgentLoopError::request_too_large("context length exceeded");
607        assert!(err.user_facing_message().contains("too long"));
608    }
609
610    #[test]
611    fn test_user_facing_error_model_not_available_includes_model_id() {
612        let err = AgentLoopError::model_not_available("gpt-99");
613        let user_error = err.user_facing_error(UserFacingErrorContext::default());
614
615        assert_eq!(user_error.code, user_facing_error_codes::MODEL_UNAVAILABLE);
616        assert_eq!(
617            user_error.fields.get("model_id"),
618            Some(&serde_json::Value::String("gpt-99".to_string()))
619        );
620    }
621
622    #[test]
623    fn test_user_facing_error_rate_limited_includes_provider_context() {
624        let err = AgentLoopError::llm("Anthropic API error (429): rate limit exceeded");
625        let user_error = err.user_facing_error(
626            UserFacingErrorContext::default()
627                .with_provider("anthropic")
628                .with_model_id("claude-sonnet-4-5")
629                .with_retry_after(12),
630        );
631
632        assert_eq!(
633            user_error.code,
634            user_facing_error_codes::PROVIDER_RATE_LIMITED
635        );
636        assert_eq!(
637            user_error.fields.get("provider"),
638            Some(&serde_json::Value::String("anthropic".to_string()))
639        );
640        assert_eq!(
641            user_error.fields.get("model_id"),
642            Some(&serde_json::Value::String("claude-sonnet-4-5".to_string()))
643        );
644        assert_eq!(
645            user_error.fields.get("retry_after"),
646            Some(&serde_json::json!(12))
647        );
648    }
649
650    #[test]
651    fn test_llm_error_kind_from_provider_status() {
652        assert_eq!(
653            LlmErrorKind::from_provider_status(401, "invalid x-api-key"),
654            LlmErrorKind::Authentication
655        );
656        assert_eq!(
657            LlmErrorKind::from_provider_status(403, "forbidden"),
658            LlmErrorKind::Authentication
659        );
660        assert_eq!(
661            LlmErrorKind::from_provider_status(429, "rate limit exceeded"),
662            LlmErrorKind::RateLimited
663        );
664        // Quota patterns win over the 429 status.
665        assert_eq!(
666            LlmErrorKind::from_provider_status(
667                429,
668                "{\"error\":{\"type\":\"insufficient_quota\"}}"
669            ),
670            LlmErrorKind::QuotaExhausted
671        );
672        // Anthropic reports exhausted billing as a 400.
673        assert_eq!(
674            LlmErrorKind::from_provider_status(
675                400,
676                "Your credit balance is too low to access the Anthropic API."
677            ),
678            LlmErrorKind::QuotaExhausted
679        );
680        assert_eq!(
681            LlmErrorKind::from_provider_status(529, "overloaded"),
682            LlmErrorKind::Unavailable
683        );
684        assert_eq!(
685            LlmErrorKind::from_provider_status(503, "unavailable"),
686            LlmErrorKind::Unavailable
687        );
688        assert_eq!(
689            LlmErrorKind::from_provider_status(400, "bad request"),
690            LlmErrorKind::InvalidRequest
691        );
692    }
693
694    #[test]
695    fn test_llm_error_kind_from_error_text_bedrock() {
696        assert_eq!(
697            LlmErrorKind::from_error_text("ThrottlingException: Too many requests"),
698            LlmErrorKind::RateLimited
699        );
700        assert_eq!(
701            LlmErrorKind::from_error_text("AccessDeniedException: not authorized"),
702            LlmErrorKind::Authentication
703        );
704        assert_eq!(
705            LlmErrorKind::from_error_text("ServiceUnavailableException"),
706            LlmErrorKind::Unavailable
707        );
708        assert_eq!(
709            LlmErrorKind::from_error_text("something else entirely"),
710            LlmErrorKind::Other
711        );
712    }
713
714    #[test]
715    fn test_user_facing_error_prefers_semantic_kind() {
716        // The message alone would string-classify as rate-limited ("429"),
717        // but the driver-assigned kind must win.
718        let err = AgentLoopError::llm_kind(
719            LlmErrorKind::QuotaExhausted,
720            "OpenAI API error (429): insufficient_quota",
721        );
722        let user_error =
723            err.user_facing_error(UserFacingErrorContext::default().with_provider("openai"));
724        assert_eq!(
725            user_error.code,
726            user_facing_error_codes::PROVIDER_QUOTA_EXHAUSTED
727        );
728        assert_eq!(
729            user_error.fields.get("provider"),
730            Some(&serde_json::Value::String("openai".to_string()))
731        );
732
733        let err = AgentLoopError::llm_kind(LlmErrorKind::Authentication, "bad key");
734        assert_eq!(
735            err.user_facing_error(UserFacingErrorContext::default())
736                .code,
737            user_facing_error_codes::PROVIDER_MISCONFIGURED
738        );
739
740        let err = AgentLoopError::llm_kind(LlmErrorKind::RateLimited, "slow down");
741        let user_error =
742            err.user_facing_error(UserFacingErrorContext::default().with_retry_after(5));
743        assert_eq!(
744            user_error.code,
745            user_facing_error_codes::PROVIDER_RATE_LIMITED
746        );
747        assert_eq!(user_error.fields.get("retry_after"), Some(&json_val(&5)));
748
749        let err = AgentLoopError::llm_kind(LlmErrorKind::Unavailable, "overloaded");
750        assert_eq!(
751            err.user_facing_error(UserFacingErrorContext::default())
752                .code,
753            user_facing_error_codes::PROVIDER_UNAVAILABLE
754        );
755    }
756
757    #[test]
758    fn test_semantic_kind_drives_predicates() {
759        assert!(AgentLoopError::llm_kind(LlmErrorKind::RateLimited, "x").is_rate_limited());
760        assert!(AgentLoopError::llm_kind(LlmErrorKind::Authentication, "x").is_auth_error());
761        assert!(AgentLoopError::llm_kind(LlmErrorKind::Unavailable, "x").is_server_error());
762        // Untyped errors keep the legacy string behavior.
763        assert!(AgentLoopError::llm("error (429)").is_rate_limited());
764        assert!(
765            !AgentLoopError::llm_kind(LlmErrorKind::Authentication, "error (429)")
766                .is_rate_limited()
767        );
768    }
769
770    #[test]
771    fn test_store_result_ext_ok() {
772        let result: std::result::Result<i32, String> = Ok(42);
773        assert_eq!(result.store_err().unwrap(), 42);
774    }
775
776    #[test]
777    fn test_store_result_ext_err() {
778        let result: std::result::Result<i32, String> = Err("db error".to_string());
779        let err = result.store_err().unwrap_err();
780        assert!(matches!(err, AgentLoopError::MessageStore(_)));
781        assert!(err.to_string().contains("db error"));
782    }
783
784    #[test]
785    fn test_json_val() {
786        let v = json_val(&vec![1, 2, 3]);
787        assert_eq!(v, serde_json::json!([1, 2, 3]));
788    }
789
790    #[test]
791    fn test_from_json() {
792        let v = serde_json::json!(["a", "b"]);
793        let result: Vec<String> = from_json(v);
794        assert_eq!(result, vec!["a", "b"]);
795    }
796
797    #[test]
798    fn test_from_json_default_on_mismatch() {
799        let v = serde_json::json!("not a number");
800        let result: i32 = from_json(v);
801        assert_eq!(result, 0);
802    }
803}