1use 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
14pub type Result<T> = std::result::Result<T, AgentLoopError>;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum LlmErrorKind {
25 Authentication,
27 QuotaExhausted,
30 RateLimited,
32 Unavailable,
34 InvalidRequest,
36 Other,
38}
39
40impl LlmErrorKind {
41 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 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#[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#[derive(Debug, Error)]
108pub enum AgentLoopError {
109 #[error("LLM error: {0}")]
111 Llm(LlmError),
112
113 #[error("Request too large: {0}")]
116 RequestTooLarge(String),
117
118 #[error("Model not available: {0}")]
121 ModelNotAvailable(String),
122
123 #[error("Tool execution error: {0}")]
125 ToolExecution(String),
126
127 #[error("Message store error: {0}")]
129 MessageStore(String),
130
131 #[error("Event emission error: {0}")]
133 EventEmission(String),
134
135 #[error("Configuration error: {0}")]
137 Configuration(String),
138
139 #[error("Max iterations ({0}) reached")]
141 MaxIterationsReached(usize),
142
143 #[error("Loop cancelled")]
145 Cancelled,
146
147 #[error("No messages to process")]
149 NoMessages,
150
151 #[error("Agent not found: {0}")]
153 AgentNotFound(AgentId),
154
155 #[error("Harness not found: {0}")]
157 HarnessNotFound(HarnessId),
158
159 #[error("Session not found: {0}")]
161 SessionNotFound(SessionId),
162
163 #[error("Internal error: {0}")]
165 Internal(#[from] anyhow::Error),
166
167 #[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 pub fn llm(msg: impl Into<String>) -> Self {
178 AgentLoopError::Llm(LlmError {
179 kind: LlmErrorKind::Other,
180 message: msg.into(),
181 })
182 }
183
184 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 pub fn llm_error_kind(&self) -> Option<LlmErrorKind> {
194 match self {
195 AgentLoopError::Llm(err) => Some(err.kind),
196 _ => None,
197 }
198 }
199
200 pub fn tool(msg: impl Into<String>) -> Self {
202 AgentLoopError::ToolExecution(msg.into())
203 }
204
205 pub fn store(msg: impl Into<String>) -> Self {
207 AgentLoopError::MessageStore(msg.into())
208 }
209
210 pub fn event(msg: impl Into<String>) -> Self {
212 AgentLoopError::EventEmission(msg.into())
213 }
214
215 pub fn config(msg: impl Into<String>) -> Self {
217 AgentLoopError::Configuration(msg.into())
218 }
219
220 pub fn agent_not_found(agent_id: AgentId) -> Self {
222 AgentLoopError::AgentNotFound(agent_id)
223 }
224
225 pub fn harness_not_found(harness_id: HarnessId) -> Self {
227 AgentLoopError::HarnessNotFound(harness_id)
228 }
229
230 pub fn session_not_found(session_id: SessionId) -> Self {
232 AgentLoopError::SessionNotFound(session_id)
233 }
234
235 pub fn driver_not_registered(provider_type: impl Into<String>) -> Self {
237 AgentLoopError::DriverNotRegistered(provider_type.into())
238 }
239
240 pub fn request_too_large(msg: impl Into<String>) -> Self {
242 AgentLoopError::RequestTooLarge(msg.into())
243 }
244
245 pub fn model_not_available(model_id: impl Into<String>) -> Self {
247 AgentLoopError::ModelNotAvailable(model_id.into())
248 }
249
250 pub fn is_request_too_large(&self) -> bool {
252 matches!(self, AgentLoopError::RequestTooLarge(_))
253 }
254
255 pub fn is_model_not_available(&self) -> bool {
257 matches!(self, AgentLoopError::ModelNotAvailable(_))
258 }
259
260 pub fn model_not_available_id(&self) -> Option<&str> {
262 match self {
263 AgentLoopError::ModelNotAvailable(id) => Some(id),
264 _ => None,
265 }
266 }
267
268 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 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 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 pub fn is_non_retryable(&self) -> bool {
330 match self {
331 AgentLoopError::AgentNotFound(_)
333 | AgentLoopError::HarnessNotFound(_)
334 | AgentLoopError::SessionNotFound(_)
335 | AgentLoopError::NoMessages => true,
336
337 AgentLoopError::Configuration(_) | AgentLoopError::DriverNotRegistered(_) => true,
339
340 AgentLoopError::MessageStore(msg) => msg.to_ascii_lowercase().contains("not found"),
342
343 _ => false,
345 }
346 }
347
348 pub fn user_facing_message(&self) -> String {
350 self.user_facing_error(UserFacingErrorContext::default())
351 .fallback_message()
352 }
353
354 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 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
411pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
456pub enum FileSystemErrorClass {
457 NotFound,
459 ReadOnly,
461 IsADirectory,
463 NotADirectory,
465 NotEmpty,
467 Other,
469}
470
471#[derive(Debug, Error)]
476pub enum FileSystemError {
477 #[error("{0}")]
478 NotFound(String),
479 #[error("{0}")]
480 ReadOnly(String),
481 #[error("{0}")]
482 IsADirectory(String),
483 #[error("{0}")]
484 NotADirectory(String),
485 #[error("{0}")]
486 NotEmpty(String),
487}
488
489impl FileSystemError {
490 fn class(&self) -> FileSystemErrorClass {
491 match self {
492 FileSystemError::NotFound(_) => FileSystemErrorClass::NotFound,
493 FileSystemError::ReadOnly(_) => FileSystemErrorClass::ReadOnly,
494 FileSystemError::IsADirectory(_) => FileSystemErrorClass::IsADirectory,
495 FileSystemError::NotADirectory(_) => FileSystemErrorClass::NotADirectory,
496 FileSystemError::NotEmpty(_) => FileSystemErrorClass::NotEmpty,
497 }
498 }
499}
500
501pub fn classify_fs_error<E>(err: &E) -> FileSystemErrorClass
511where
512 E: std::error::Error + 'static,
513{
514 let mut source: Option<&(dyn std::error::Error + 'static)> = Some(err);
519 while let Some(current) = source {
520 if let Some(typed) = current.downcast_ref::<FileSystemError>() {
521 return typed.class();
522 }
523 source = current.source();
524 }
525
526 let msg = err.to_string();
527 if msg.contains("readonly") {
531 FileSystemErrorClass::ReadOnly
532 } else if msg.contains("is a directory") {
533 FileSystemErrorClass::IsADirectory
534 } else if msg.contains("not a directory") {
535 FileSystemErrorClass::NotADirectory
536 } else if msg.contains("not empty") || msg.contains("recursive") {
537 FileSystemErrorClass::NotEmpty
538 } else if msg.contains("not found") {
539 FileSystemErrorClass::NotFound
540 } else {
541 FileSystemErrorClass::Other
542 }
543}
544
545pub fn json_val<T: Serialize>(value: &T) -> serde_json::Value {
556 serde_json::to_value(value).unwrap_or_default()
557}
558
559pub fn from_json<T: DeserializeOwned + Default>(value: serde_json::Value) -> T {
566 serde_json::from_value(value).unwrap_or_default()
567}
568
569#[cfg(test)]
570mod tests {
571 use super::*;
572
573 #[test]
577 fn classify_fs_error_prefers_typed_variant() {
578 let err = FileSystemError::ReadOnly("x".into());
579 assert_eq!(classify_fs_error(&err), FileSystemErrorClass::ReadOnly);
580 let err = FileSystemError::IsADirectory("x".into());
581 assert_eq!(classify_fs_error(&err), FileSystemErrorClass::IsADirectory);
582 }
583
584 #[test]
585 fn classify_fs_error_substring_fallback_matches_real_producers() {
586 let cases = [
589 (
590 "Cannot modify readonly file: /a",
591 FileSystemErrorClass::ReadOnly,
592 ),
593 (
594 "Cannot delete readonly file: /a",
595 FileSystemErrorClass::ReadOnly,
596 ),
597 (
598 "write target is a directory: /a",
599 FileSystemErrorClass::IsADirectory,
600 ),
601 (
602 "Path is not a directory: /a",
603 FileSystemErrorClass::NotADirectory,
604 ),
605 (
606 "workspace root is not a directory: /a",
607 FileSystemErrorClass::NotADirectory,
608 ),
609 ("Directory not found: /a", FileSystemErrorClass::NotFound),
610 (
611 "Directory is not empty. Use recursive=true to delete",
612 FileSystemErrorClass::NotEmpty,
613 ),
614 (
615 "Cannot delete root directory without recursive flag",
616 FileSystemErrorClass::NotEmpty,
617 ),
618 (
619 "recursive delete failed for /a: io",
620 FileSystemErrorClass::NotEmpty,
621 ),
622 ("disk full", FileSystemErrorClass::Other),
623 ];
624 for (msg, expected) in cases {
625 let err = AgentLoopError::store(msg);
626 assert_eq!(classify_fs_error(&err), expected, "msg: {msg}");
627 }
628 }
629
630 #[test]
633 fn classify_fs_error_classifies_typed_directly() {
634 let err = FileSystemError::NotEmpty("anything at all".into());
635 assert_eq!(classify_fs_error(&err), FileSystemErrorClass::NotEmpty);
636 }
637
638 #[test]
641 fn classify_fs_error_does_not_match_hyphenated_read_only() {
642 let err = AgentLoopError::store("file is read-only: /a");
643 assert_eq!(classify_fs_error(&err), FileSystemErrorClass::Other);
644 }
645
646 #[test]
647 fn test_is_request_too_large_returns_true_for_typed_error() {
648 let err = AgentLoopError::request_too_large("context length exceeded");
649 assert!(err.is_request_too_large());
650 }
651
652 #[test]
653 fn test_is_request_too_large_returns_false_for_llm_error() {
654 let err = AgentLoopError::llm("OpenAI API error (500): Internal server error");
655 assert!(!err.is_request_too_large());
656 }
657
658 #[test]
659 fn test_is_request_too_large_returns_false_for_other_errors() {
660 let err = AgentLoopError::ToolExecution("some error".to_string());
661 assert!(!err.is_request_too_large());
662
663 let err = AgentLoopError::Cancelled;
664 assert!(!err.is_request_too_large());
665 }
666
667 #[test]
668 fn test_request_too_large_error_preserves_message() {
669 let original_msg = "OpenAI API error (429): Request too large for gpt-4";
670 let err = AgentLoopError::request_too_large(original_msg);
671 assert_eq!(
672 err.to_string(),
673 format!("Request too large: {}", original_msg)
674 );
675 }
676
677 #[test]
678 fn test_is_model_not_available_returns_true_for_typed_error() {
679 let err = AgentLoopError::model_not_available("claude-sonnet-4-6-20260217");
680 assert!(err.is_model_not_available());
681 assert_eq!(
682 err.model_not_available_id(),
683 Some("claude-sonnet-4-6-20260217")
684 );
685 }
686
687 #[test]
688 fn test_is_model_not_available_returns_false_for_llm_error() {
689 let err = AgentLoopError::llm("some error");
690 assert!(!err.is_model_not_available());
691 assert_eq!(err.model_not_available_id(), None);
692 }
693
694 #[test]
695 fn test_model_not_available_error_display() {
696 let err = AgentLoopError::model_not_available("gpt-99");
697 assert_eq!(err.to_string(), "Model not available: gpt-99");
698 }
699
700 #[test]
701 fn test_is_rate_limited_detects_429() {
702 let err = AgentLoopError::llm("Anthropic API error (429): rate limit exceeded");
703 assert!(err.is_rate_limited());
704 }
705
706 #[test]
707 fn test_is_rate_limited_detects_rate_limit_keyword() {
708 let err =
709 AgentLoopError::llm("Rate limit exceeded (after 2 retries, last error: too many)");
710 assert!(err.is_rate_limited());
711 }
712
713 #[test]
714 fn test_is_rate_limited_false_for_server_error() {
715 let err = AgentLoopError::llm("Anthropic API error (500): internal server error");
716 assert!(!err.is_rate_limited());
717 }
718
719 #[test]
720 fn test_is_auth_error_detects_401() {
721 let err = AgentLoopError::llm("Anthropic API error (401): invalid api key");
722 assert!(err.is_auth_error());
723 }
724
725 #[test]
726 fn test_is_auth_error_detects_403() {
727 let err = AgentLoopError::llm("OpenAI API error (403): forbidden");
728 assert!(err.is_auth_error());
729 }
730
731 #[test]
732 fn test_is_server_error_detects_500() {
733 let err = AgentLoopError::llm("Anthropic API error (500): internal server error");
734 assert!(err.is_server_error());
735 }
736
737 #[test]
738 fn test_is_server_error_detects_503() {
739 let err = AgentLoopError::llm("OpenAI API error (503): service unavailable");
740 assert!(err.is_server_error());
741 }
742
743 #[test]
744 fn test_user_facing_message_rate_limited() {
745 let err = AgentLoopError::llm("Anthropic API error (429): rate limit exceeded");
746 assert_eq!(
747 err.user_facing_message(),
748 "Rate limited by the AI provider. Please wait a moment."
749 );
750 }
751
752 #[test]
753 fn test_user_facing_message_auth_error() {
754 let err = AgentLoopError::llm("Anthropic API error (401): invalid api key");
755 assert_eq!(
756 err.user_facing_message(),
757 "There is a misconfiguration with the AI provider. Please contact support."
758 );
759 }
760
761 #[test]
762 fn test_user_facing_message_server_error() {
763 let err = AgentLoopError::llm("Anthropic API error (500): internal server error");
764 assert_eq!(
765 err.user_facing_message(),
766 "The AI provider is experiencing issues. Please try again shortly."
767 );
768 }
769
770 #[test]
771 fn test_user_facing_message_generic_fallback() {
772 let err = AgentLoopError::llm("Failed to send request: connection refused");
773 assert_eq!(
774 err.user_facing_message(),
775 "I encountered an error while processing your request. Please try again later."
776 );
777 }
778
779 #[test]
780 fn test_user_facing_message_model_not_available() {
781 let err = AgentLoopError::model_not_available("gpt-99");
782 assert!(err.user_facing_message().contains("gpt-99"));
783 assert!(err.user_facing_message().contains("not available"));
784 }
785
786 #[test]
787 fn test_user_facing_message_request_too_large() {
788 let err = AgentLoopError::request_too_large("context length exceeded");
789 assert!(err.user_facing_message().contains("too long"));
790 }
791
792 #[test]
793 fn test_user_facing_error_model_not_available_includes_model_id() {
794 let err = AgentLoopError::model_not_available("gpt-99");
795 let user_error = err.user_facing_error(UserFacingErrorContext::default());
796
797 assert_eq!(user_error.code, user_facing_error_codes::MODEL_UNAVAILABLE);
798 assert_eq!(
799 user_error.fields.get("model_id"),
800 Some(&serde_json::Value::String("gpt-99".to_string()))
801 );
802 }
803
804 #[test]
805 fn test_user_facing_error_rate_limited_includes_provider_context() {
806 let err = AgentLoopError::llm("Anthropic API error (429): rate limit exceeded");
807 let user_error = err.user_facing_error(
808 UserFacingErrorContext::default()
809 .with_provider("anthropic")
810 .with_model_id("claude-sonnet-4-5")
811 .with_retry_after(12),
812 );
813
814 assert_eq!(
815 user_error.code,
816 user_facing_error_codes::PROVIDER_RATE_LIMITED
817 );
818 assert_eq!(
819 user_error.fields.get("provider"),
820 Some(&serde_json::Value::String("anthropic".to_string()))
821 );
822 assert_eq!(
823 user_error.fields.get("model_id"),
824 Some(&serde_json::Value::String("claude-sonnet-4-5".to_string()))
825 );
826 assert_eq!(
827 user_error.fields.get("retry_after"),
828 Some(&serde_json::json!(12))
829 );
830 }
831
832 #[test]
833 fn test_llm_error_kind_from_provider_status() {
834 assert_eq!(
835 LlmErrorKind::from_provider_status(401, "invalid x-api-key"),
836 LlmErrorKind::Authentication
837 );
838 assert_eq!(
839 LlmErrorKind::from_provider_status(403, "forbidden"),
840 LlmErrorKind::Authentication
841 );
842 assert_eq!(
843 LlmErrorKind::from_provider_status(429, "rate limit exceeded"),
844 LlmErrorKind::RateLimited
845 );
846 assert_eq!(
848 LlmErrorKind::from_provider_status(
849 429,
850 "{\"error\":{\"type\":\"insufficient_quota\"}}"
851 ),
852 LlmErrorKind::QuotaExhausted
853 );
854 assert_eq!(
856 LlmErrorKind::from_provider_status(
857 400,
858 "Your credit balance is too low to access the Anthropic API."
859 ),
860 LlmErrorKind::QuotaExhausted
861 );
862 assert_eq!(
863 LlmErrorKind::from_provider_status(529, "overloaded"),
864 LlmErrorKind::Unavailable
865 );
866 assert_eq!(
867 LlmErrorKind::from_provider_status(503, "unavailable"),
868 LlmErrorKind::Unavailable
869 );
870 assert_eq!(
871 LlmErrorKind::from_provider_status(400, "bad request"),
872 LlmErrorKind::InvalidRequest
873 );
874 }
875
876 #[test]
877 fn test_llm_error_kind_from_error_text_bedrock() {
878 assert_eq!(
879 LlmErrorKind::from_error_text("ThrottlingException: Too many requests"),
880 LlmErrorKind::RateLimited
881 );
882 assert_eq!(
883 LlmErrorKind::from_error_text("AccessDeniedException: not authorized"),
884 LlmErrorKind::Authentication
885 );
886 assert_eq!(
887 LlmErrorKind::from_error_text("ServiceUnavailableException"),
888 LlmErrorKind::Unavailable
889 );
890 assert_eq!(
891 LlmErrorKind::from_error_text("something else entirely"),
892 LlmErrorKind::Other
893 );
894 }
895
896 #[test]
897 fn test_user_facing_error_prefers_semantic_kind() {
898 let err = AgentLoopError::llm_kind(
901 LlmErrorKind::QuotaExhausted,
902 "OpenAI API error (429): insufficient_quota",
903 );
904 let user_error =
905 err.user_facing_error(UserFacingErrorContext::default().with_provider("openai"));
906 assert_eq!(
907 user_error.code,
908 user_facing_error_codes::PROVIDER_QUOTA_EXHAUSTED
909 );
910 assert_eq!(
911 user_error.fields.get("provider"),
912 Some(&serde_json::Value::String("openai".to_string()))
913 );
914
915 let err = AgentLoopError::llm_kind(LlmErrorKind::Authentication, "bad key");
916 assert_eq!(
917 err.user_facing_error(UserFacingErrorContext::default())
918 .code,
919 user_facing_error_codes::PROVIDER_MISCONFIGURED
920 );
921
922 let err = AgentLoopError::llm_kind(LlmErrorKind::RateLimited, "slow down");
923 let user_error =
924 err.user_facing_error(UserFacingErrorContext::default().with_retry_after(5));
925 assert_eq!(
926 user_error.code,
927 user_facing_error_codes::PROVIDER_RATE_LIMITED
928 );
929 assert_eq!(user_error.fields.get("retry_after"), Some(&json_val(&5)));
930
931 let err = AgentLoopError::llm_kind(LlmErrorKind::Unavailable, "overloaded");
932 assert_eq!(
933 err.user_facing_error(UserFacingErrorContext::default())
934 .code,
935 user_facing_error_codes::PROVIDER_UNAVAILABLE
936 );
937 }
938
939 #[test]
940 fn test_semantic_kind_drives_predicates() {
941 assert!(AgentLoopError::llm_kind(LlmErrorKind::RateLimited, "x").is_rate_limited());
942 assert!(AgentLoopError::llm_kind(LlmErrorKind::Authentication, "x").is_auth_error());
943 assert!(AgentLoopError::llm_kind(LlmErrorKind::Unavailable, "x").is_server_error());
944 assert!(AgentLoopError::llm("error (429)").is_rate_limited());
946 assert!(
947 !AgentLoopError::llm_kind(LlmErrorKind::Authentication, "error (429)")
948 .is_rate_limited()
949 );
950 }
951
952 #[test]
953 fn test_store_result_ext_ok() {
954 let result: std::result::Result<i32, String> = Ok(42);
955 assert_eq!(result.store_err().unwrap(), 42);
956 }
957
958 #[test]
959 fn test_store_result_ext_err() {
960 let result: std::result::Result<i32, String> = Err("db error".to_string());
961 let err = result.store_err().unwrap_err();
962 assert!(matches!(err, AgentLoopError::MessageStore(_)));
963 assert!(err.to_string().contains("db error"));
964 }
965
966 #[test]
967 fn test_json_val() {
968 let v = json_val(&vec![1, 2, 3]);
969 assert_eq!(v, serde_json::json!([1, 2, 3]));
970 }
971
972 #[test]
973 fn test_from_json() {
974 let v = serde_json::json!(["a", "b"]);
975 let result: Vec<String> = from_json(v);
976 assert_eq!(result, vec!["a", "b"]);
977 }
978
979 #[test]
980 fn test_from_json_default_on_mismatch() {
981 let v = serde_json::json!("not a number");
982 let result: i32 = from_json(v);
983 assert_eq!(result, 0);
984 }
985}