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_code(code: &str) -> Option<Self> {
43 let code = code.trim().to_ascii_lowercase();
44 match code.as_str() {
45 "insufficient_quota" | "billing_hard_limit_reached" | "credit_balance_too_low" => {
46 Some(Self::QuotaExhausted)
47 }
48 "authentication_error" | "invalid_api_key" | "permission_denied" => {
49 Some(Self::Authentication)
50 }
51 "rate_limit_exceeded" | "rate_limit_error" | "overloaded_error" => {
52 Some(Self::RateLimited)
53 }
54 "server_error"
55 | "internal_error"
56 | "processing_error"
57 | "service_unavailable"
58 | "timeout" => Some(Self::Unavailable),
59 "invalid_request_error" | "model_not_found" => Some(Self::InvalidRequest),
60 _ => None,
61 }
62 }
63
64 pub fn from_provider_status(status: u16, body: &str) -> Self {
71 if is_provider_quota_message(body) {
72 return LlmErrorKind::QuotaExhausted;
73 }
74 match status {
75 401 | 403 => LlmErrorKind::Authentication,
76 429 => LlmErrorKind::RateLimited,
77 408 | 409 => LlmErrorKind::Unavailable,
78 501 => LlmErrorKind::Other,
79 500..=599 => LlmErrorKind::Unavailable,
80 400..=499 => LlmErrorKind::InvalidRequest,
81 _ => LlmErrorKind::Other,
82 }
83 }
84
85 pub fn from_error_text(text: &str) -> Self {
88 if is_provider_quota_message(text) {
89 return LlmErrorKind::QuotaExhausted;
90 }
91 let lower = text.to_ascii_lowercase();
92 if lower.contains("throttlingexception")
93 || lower.contains("toomanyrequestsexception")
94 || lower.contains("rate limit")
95 || lower.contains("too many requests")
96 {
97 return LlmErrorKind::RateLimited;
98 }
99 if lower.contains("accessdeniedexception")
100 || lower.contains("unrecognizedclientexception")
101 || lower.contains("expiredtokenexception")
102 || lower.contains("invalidsignatureexception")
103 || lower.contains("unauthorized")
104 {
105 return LlmErrorKind::Authentication;
106 }
107 if lower.contains("serviceunavailable")
108 || lower.contains("service unavailable")
109 || lower.contains("internalserverexception")
110 || lower.contains("modelnotreadyexception")
111 {
112 return LlmErrorKind::Unavailable;
113 }
114 LlmErrorKind::Other
115 }
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct LlmError {
121 pub kind: LlmErrorKind,
122 pub message: String,
123}
124
125impl std::fmt::Display for LlmError {
126 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127 f.write_str(&self.message)
128 }
129}
130
131#[derive(Debug, Error)]
133pub enum AgentLoopError {
134 #[error("LLM error: {0}")]
136 Llm(LlmError),
137
138 #[error("Request too large: {0}")]
141 RequestTooLarge(String),
142
143 #[error("Model not available: {0}")]
146 ModelNotAvailable(String),
147
148 #[error("Tool execution error: {0}")]
150 ToolExecution(String),
151
152 #[error("Message store error: {0}")]
154 MessageStore(String),
155
156 #[error("Event emission error: {0}")]
158 EventEmission(String),
159
160 #[error("Configuration error: {0}")]
162 Configuration(String),
163
164 #[error("Max iterations ({0}) reached")]
166 MaxIterationsReached(usize),
167
168 #[error("Loop cancelled")]
170 Cancelled,
171
172 #[error("No messages to process")]
174 NoMessages,
175
176 #[error("Agent not found: {0}")]
178 AgentNotFound(AgentId),
179
180 #[error("Harness not found: {0}")]
182 HarnessNotFound(HarnessId),
183
184 #[error("Session not found: {0}")]
186 SessionNotFound(SessionId),
187
188 #[error("Internal error: {0}")]
190 Internal(#[from] anyhow::Error),
191
192 #[error(
194 "No driver registered for provider type '{0}'. Make sure the driver is registered at startup."
195 )]
196 DriverNotRegistered(String),
197}
198
199impl AgentLoopError {
200 pub fn llm(msg: impl Into<String>) -> Self {
203 AgentLoopError::Llm(LlmError {
204 kind: LlmErrorKind::Other,
205 message: msg.into(),
206 })
207 }
208
209 pub fn llm_kind(kind: LlmErrorKind, msg: impl Into<String>) -> Self {
211 AgentLoopError::Llm(LlmError {
212 kind,
213 message: msg.into(),
214 })
215 }
216
217 pub fn llm_error_kind(&self) -> Option<LlmErrorKind> {
219 match self {
220 AgentLoopError::Llm(err) => Some(err.kind),
221 _ => None,
222 }
223 }
224
225 pub fn tool(msg: impl Into<String>) -> Self {
227 AgentLoopError::ToolExecution(msg.into())
228 }
229
230 pub fn store(msg: impl Into<String>) -> Self {
232 AgentLoopError::MessageStore(msg.into())
233 }
234
235 pub fn event(msg: impl Into<String>) -> Self {
237 AgentLoopError::EventEmission(msg.into())
238 }
239
240 pub fn config(msg: impl Into<String>) -> Self {
242 AgentLoopError::Configuration(msg.into())
243 }
244
245 pub fn agent_not_found(agent_id: AgentId) -> Self {
247 AgentLoopError::AgentNotFound(agent_id)
248 }
249
250 pub fn harness_not_found(harness_id: HarnessId) -> Self {
252 AgentLoopError::HarnessNotFound(harness_id)
253 }
254
255 pub fn session_not_found(session_id: SessionId) -> Self {
257 AgentLoopError::SessionNotFound(session_id)
258 }
259
260 pub fn driver_not_registered(provider_type: impl Into<String>) -> Self {
262 AgentLoopError::DriverNotRegistered(provider_type.into())
263 }
264
265 pub fn request_too_large(msg: impl Into<String>) -> Self {
267 AgentLoopError::RequestTooLarge(msg.into())
268 }
269
270 pub fn model_not_available(model_id: impl Into<String>) -> Self {
272 AgentLoopError::ModelNotAvailable(model_id.into())
273 }
274
275 pub fn is_request_too_large(&self) -> bool {
277 matches!(self, AgentLoopError::RequestTooLarge(_))
278 }
279
280 pub fn is_model_not_available(&self) -> bool {
282 matches!(self, AgentLoopError::ModelNotAvailable(_))
283 }
284
285 pub fn model_not_available_id(&self) -> Option<&str> {
287 match self {
288 AgentLoopError::ModelNotAvailable(id) => Some(id),
289 _ => None,
290 }
291 }
292
293 pub fn is_rate_limited(&self) -> bool {
296 match self {
297 AgentLoopError::Llm(err) => match err.kind {
298 LlmErrorKind::RateLimited => true,
299 LlmErrorKind::Other => {
300 let msg_lower = err.message.to_ascii_lowercase();
301 msg_lower.contains("(429)")
302 || msg_lower.contains("rate limit")
303 || msg_lower.contains("too many requests")
304 }
305 _ => false,
306 },
307 _ => false,
308 }
309 }
310
311 pub fn is_auth_error(&self) -> bool {
313 match self {
314 AgentLoopError::Llm(err) => match err.kind {
315 LlmErrorKind::Authentication => true,
316 LlmErrorKind::Other => {
317 err.message.contains("(401)") || err.message.contains("(403)")
318 }
319 _ => false,
320 },
321 _ => false,
322 }
323 }
324
325 pub fn is_server_error(&self) -> bool {
327 match self {
328 AgentLoopError::Llm(err) => match err.kind {
329 LlmErrorKind::Unavailable => true,
330 LlmErrorKind::Other => {
331 let msg = &err.message;
332 msg.contains("(500)")
333 || msg.contains("(502)")
334 || msg.contains("(503)")
335 || msg.contains("(504)")
336 || msg.contains("(529)")
337 }
338 _ => false,
339 },
340 _ => false,
341 }
342 }
343
344 pub fn is_transient_llm_error(&self) -> bool {
349 match self {
350 AgentLoopError::Llm(err) => match err.kind {
351 LlmErrorKind::RateLimited | LlmErrorKind::Unavailable => true,
352 LlmErrorKind::Authentication
353 | LlmErrorKind::QuotaExhausted
354 | LlmErrorKind::InvalidRequest => false,
355 LlmErrorKind::Other => crate::llm_retry::is_transient_error_message(&err.message),
356 },
357 _ => false,
358 }
359 }
360
361 pub fn is_non_retryable(&self) -> bool {
372 match self {
373 AgentLoopError::AgentNotFound(_)
375 | AgentLoopError::HarnessNotFound(_)
376 | AgentLoopError::SessionNotFound(_)
377 | AgentLoopError::NoMessages => true,
378
379 AgentLoopError::Configuration(_) | AgentLoopError::DriverNotRegistered(_) => true,
381
382 AgentLoopError::MessageStore(msg) => msg.to_ascii_lowercase().contains("not found"),
384
385 _ => false,
387 }
388 }
389
390 pub fn user_facing_message(&self) -> String {
392 self.user_facing_error(UserFacingErrorContext::default())
393 .fallback_message()
394 }
395
396 pub fn user_facing_error(&self, context: UserFacingErrorContext) -> UserFacingError {
398 match self {
399 AgentLoopError::ModelNotAvailable(model_id) => {
400 UserFacingError::new(user_facing_error_codes::MODEL_UNAVAILABLE)
401 .with_field("model_id", model_id)
402 .with_optional_field("provider", context.provider)
403 }
404 AgentLoopError::RequestTooLarge(_) => {
405 UserFacingError::new(user_facing_error_codes::REQUEST_TOO_LARGE)
406 .with_optional_field("provider", context.provider)
407 .with_optional_field("model_id", context.model_id)
408 }
409 AgentLoopError::MaxIterationsReached(max_iterations) => {
410 UserFacingError::new(user_facing_error_codes::MAX_ITERATIONS)
411 .with_field("max_iterations", max_iterations)
412 }
413 AgentLoopError::Llm(err) => {
414 let code = match err.kind {
418 LlmErrorKind::Authentication => {
419 Some(user_facing_error_codes::PROVIDER_MISCONFIGURED)
420 }
421 LlmErrorKind::QuotaExhausted => {
422 Some(user_facing_error_codes::PROVIDER_QUOTA_EXHAUSTED)
423 }
424 LlmErrorKind::RateLimited => {
425 Some(user_facing_error_codes::PROVIDER_RATE_LIMITED)
426 }
427 LlmErrorKind::Unavailable => {
428 Some(user_facing_error_codes::PROVIDER_UNAVAILABLE)
429 }
430 LlmErrorKind::InvalidRequest | LlmErrorKind::Other => None,
431 };
432 match code {
433 Some(code) => {
434 let error = UserFacingError::new(code)
435 .with_optional_field("provider", context.provider)
436 .with_optional_field("model_id", context.model_id);
437 if code == user_facing_error_codes::PROVIDER_RATE_LIMITED {
438 error.with_optional_field("retry_after", context.retry_after)
439 } else {
440 error
441 }
442 }
443 None => classify_runtime_error_message(&err.message, &context),
444 }
445 }
446 _ => UserFacingError::new(user_facing_error_codes::PROCESSING_ERROR)
447 .with_optional_field("provider", context.provider)
448 .with_optional_field("model_id", context.model_id),
449 }
450 }
451}
452
453pub trait StoreResultExt<T> {
469 fn store_err(self) -> Result<T>;
470}
471
472impl<T, E: std::fmt::Display> StoreResultExt<T> for std::result::Result<T, E> {
473 fn store_err(self) -> Result<T> {
474 self.map_err(|e| AgentLoopError::store(e.to_string()))
475 }
476}
477
478#[derive(Debug, Clone, Copy, PartialEq, Eq)]
498pub enum FileSystemErrorClass {
499 NotFound,
501 ReadOnly,
503 IsADirectory,
505 NotADirectory,
507 NotEmpty,
509 Other,
511}
512
513#[derive(Debug, Error)]
518pub enum FileSystemError {
519 #[error("{0}")]
520 NotFound(String),
521 #[error("{0}")]
522 ReadOnly(String),
523 #[error("{0}")]
524 IsADirectory(String),
525 #[error("{0}")]
526 NotADirectory(String),
527 #[error("{0}")]
528 NotEmpty(String),
529}
530
531impl FileSystemError {
532 fn class(&self) -> FileSystemErrorClass {
533 match self {
534 FileSystemError::NotFound(_) => FileSystemErrorClass::NotFound,
535 FileSystemError::ReadOnly(_) => FileSystemErrorClass::ReadOnly,
536 FileSystemError::IsADirectory(_) => FileSystemErrorClass::IsADirectory,
537 FileSystemError::NotADirectory(_) => FileSystemErrorClass::NotADirectory,
538 FileSystemError::NotEmpty(_) => FileSystemErrorClass::NotEmpty,
539 }
540 }
541}
542
543pub fn classify_fs_error<E>(err: &E) -> FileSystemErrorClass
553where
554 E: std::error::Error + 'static,
555{
556 let mut source: Option<&(dyn std::error::Error + 'static)> = Some(err);
561 while let Some(current) = source {
562 if let Some(typed) = current.downcast_ref::<FileSystemError>() {
563 return typed.class();
564 }
565 source = current.source();
566 }
567
568 let msg = err.to_string();
569 if msg.contains("readonly") {
573 FileSystemErrorClass::ReadOnly
574 } else if msg.contains("is a directory") {
575 FileSystemErrorClass::IsADirectory
576 } else if msg.contains("not a directory") {
577 FileSystemErrorClass::NotADirectory
578 } else if msg.contains("not empty") || msg.contains("recursive") {
579 FileSystemErrorClass::NotEmpty
580 } else if msg.contains("not found") {
581 FileSystemErrorClass::NotFound
582 } else {
583 FileSystemErrorClass::Other
584 }
585}
586
587pub fn json_val<T: Serialize>(value: &T) -> serde_json::Value {
598 serde_json::to_value(value).unwrap_or_default()
599}
600
601pub fn from_json<T: DeserializeOwned + Default>(value: serde_json::Value) -> T {
608 serde_json::from_value(value).unwrap_or_default()
609}
610
611#[cfg(test)]
612mod tests {
613 use super::*;
614
615 #[test]
619 fn classify_fs_error_prefers_typed_variant() {
620 let err = FileSystemError::ReadOnly("x".into());
621 assert_eq!(classify_fs_error(&err), FileSystemErrorClass::ReadOnly);
622 let err = FileSystemError::IsADirectory("x".into());
623 assert_eq!(classify_fs_error(&err), FileSystemErrorClass::IsADirectory);
624 }
625
626 #[test]
627 fn classify_fs_error_substring_fallback_matches_real_producers() {
628 let cases = [
631 (
632 "Cannot modify readonly file: /a",
633 FileSystemErrorClass::ReadOnly,
634 ),
635 (
636 "Cannot delete readonly file: /a",
637 FileSystemErrorClass::ReadOnly,
638 ),
639 (
640 "write target is a directory: /a",
641 FileSystemErrorClass::IsADirectory,
642 ),
643 (
644 "Path is not a directory: /a",
645 FileSystemErrorClass::NotADirectory,
646 ),
647 (
648 "workspace root is not a directory: /a",
649 FileSystemErrorClass::NotADirectory,
650 ),
651 ("Directory not found: /a", FileSystemErrorClass::NotFound),
652 (
653 "Directory is not empty. Use recursive=true to delete",
654 FileSystemErrorClass::NotEmpty,
655 ),
656 (
657 "Cannot delete root directory without recursive flag",
658 FileSystemErrorClass::NotEmpty,
659 ),
660 (
661 "recursive delete failed for /a: io",
662 FileSystemErrorClass::NotEmpty,
663 ),
664 ("disk full", FileSystemErrorClass::Other),
665 ];
666 for (msg, expected) in cases {
667 let err = AgentLoopError::store(msg);
668 assert_eq!(classify_fs_error(&err), expected, "msg: {msg}");
669 }
670 }
671
672 #[test]
675 fn classify_fs_error_classifies_typed_directly() {
676 let err = FileSystemError::NotEmpty("anything at all".into());
677 assert_eq!(classify_fs_error(&err), FileSystemErrorClass::NotEmpty);
678 }
679
680 #[test]
683 fn classify_fs_error_does_not_match_hyphenated_read_only() {
684 let err = AgentLoopError::store("file is read-only: /a");
685 assert_eq!(classify_fs_error(&err), FileSystemErrorClass::Other);
686 }
687
688 #[test]
689 fn test_is_request_too_large_returns_true_for_typed_error() {
690 let err = AgentLoopError::request_too_large("context length exceeded");
691 assert!(err.is_request_too_large());
692 }
693
694 #[test]
695 fn test_is_request_too_large_returns_false_for_llm_error() {
696 let err = AgentLoopError::llm("OpenAI API error (500): Internal server error");
697 assert!(!err.is_request_too_large());
698 }
699
700 #[test]
701 fn test_is_request_too_large_returns_false_for_other_errors() {
702 let err = AgentLoopError::ToolExecution("some error".to_string());
703 assert!(!err.is_request_too_large());
704
705 let err = AgentLoopError::Cancelled;
706 assert!(!err.is_request_too_large());
707 }
708
709 #[test]
710 fn test_request_too_large_error_preserves_message() {
711 let original_msg = "OpenAI API error (429): Request too large for gpt-4";
712 let err = AgentLoopError::request_too_large(original_msg);
713 assert_eq!(
714 err.to_string(),
715 format!("Request too large: {}", original_msg)
716 );
717 }
718
719 #[test]
720 fn test_is_model_not_available_returns_true_for_typed_error() {
721 let err = AgentLoopError::model_not_available("claude-sonnet-4-6-20260217");
722 assert!(err.is_model_not_available());
723 assert_eq!(
724 err.model_not_available_id(),
725 Some("claude-sonnet-4-6-20260217")
726 );
727 }
728
729 #[test]
730 fn test_is_model_not_available_returns_false_for_llm_error() {
731 let err = AgentLoopError::llm("some error");
732 assert!(!err.is_model_not_available());
733 assert_eq!(err.model_not_available_id(), None);
734 }
735
736 #[test]
737 fn test_model_not_available_error_display() {
738 let err = AgentLoopError::model_not_available("gpt-99");
739 assert_eq!(err.to_string(), "Model not available: gpt-99");
740 }
741
742 #[test]
743 fn test_is_rate_limited_detects_429() {
744 let err = AgentLoopError::llm("Anthropic API error (429): rate limit exceeded");
745 assert!(err.is_rate_limited());
746 }
747
748 #[test]
749 fn test_is_rate_limited_detects_rate_limit_keyword() {
750 let err =
751 AgentLoopError::llm("Rate limit exceeded (after 2 retries, last error: too many)");
752 assert!(err.is_rate_limited());
753 }
754
755 #[test]
756 fn test_is_rate_limited_false_for_server_error() {
757 let err = AgentLoopError::llm("Anthropic API error (500): internal server error");
758 assert!(!err.is_rate_limited());
759 }
760
761 #[test]
762 fn test_is_auth_error_detects_401() {
763 let err = AgentLoopError::llm("Anthropic API error (401): invalid api key");
764 assert!(err.is_auth_error());
765 }
766
767 #[test]
768 fn test_is_auth_error_detects_403() {
769 let err = AgentLoopError::llm("OpenAI API error (403): forbidden");
770 assert!(err.is_auth_error());
771 }
772
773 #[test]
774 fn test_is_server_error_detects_500() {
775 let err = AgentLoopError::llm("Anthropic API error (500): internal server error");
776 assert!(err.is_server_error());
777 }
778
779 #[test]
780 fn test_is_server_error_detects_503() {
781 let err = AgentLoopError::llm("OpenAI API error (503): service unavailable");
782 assert!(err.is_server_error());
783 }
784
785 #[test]
786 fn test_user_facing_message_rate_limited() {
787 let err = AgentLoopError::llm("Anthropic API error (429): rate limit exceeded");
788 assert_eq!(
789 err.user_facing_message(),
790 "Rate limited by the AI provider. Please wait a moment."
791 );
792 }
793
794 #[test]
795 fn test_user_facing_message_auth_error() {
796 let err = AgentLoopError::llm("Anthropic API error (401): invalid api key");
797 assert_eq!(
798 err.user_facing_message(),
799 "There is a misconfiguration with the AI provider. Please contact support."
800 );
801 }
802
803 #[test]
804 fn test_user_facing_message_server_error() {
805 let err = AgentLoopError::llm("Anthropic API error (500): internal server error");
806 assert_eq!(
807 err.user_facing_message(),
808 "The AI provider is experiencing issues. Please try again shortly."
809 );
810 }
811
812 #[test]
813 fn test_user_facing_message_generic_fallback() {
814 let err = AgentLoopError::llm("Failed to send request: connection refused");
815 assert_eq!(
816 err.user_facing_message(),
817 "I encountered an error while processing your request. Please try again later."
818 );
819 }
820
821 #[test]
822 fn test_user_facing_message_model_not_available() {
823 let err = AgentLoopError::model_not_available("gpt-99");
824 assert!(err.user_facing_message().contains("gpt-99"));
825 assert!(err.user_facing_message().contains("not available"));
826 }
827
828 #[test]
829 fn test_user_facing_message_request_too_large() {
830 let err = AgentLoopError::request_too_large("context length exceeded");
831 assert!(err.user_facing_message().contains("too long"));
832 }
833
834 #[test]
835 fn test_user_facing_error_model_not_available_includes_model_id() {
836 let err = AgentLoopError::model_not_available("gpt-99");
837 let user_error = err.user_facing_error(UserFacingErrorContext::default());
838
839 assert_eq!(user_error.code, user_facing_error_codes::MODEL_UNAVAILABLE);
840 assert_eq!(
841 user_error.fields.get("model_id"),
842 Some(&serde_json::Value::String("gpt-99".to_string()))
843 );
844 }
845
846 #[test]
847 fn test_user_facing_error_rate_limited_includes_provider_context() {
848 let err = AgentLoopError::llm("Anthropic API error (429): rate limit exceeded");
849 let user_error = err.user_facing_error(
850 UserFacingErrorContext::default()
851 .with_provider("anthropic")
852 .with_model_id("claude-sonnet-4-5")
853 .with_retry_after(12),
854 );
855
856 assert_eq!(
857 user_error.code,
858 user_facing_error_codes::PROVIDER_RATE_LIMITED
859 );
860 assert_eq!(
861 user_error.fields.get("provider"),
862 Some(&serde_json::Value::String("anthropic".to_string()))
863 );
864 assert_eq!(
865 user_error.fields.get("model_id"),
866 Some(&serde_json::Value::String("claude-sonnet-4-5".to_string()))
867 );
868 assert_eq!(
869 user_error.fields.get("retry_after"),
870 Some(&serde_json::json!(12))
871 );
872 }
873
874 #[test]
875 fn test_llm_error_kind_from_provider_status() {
876 assert_eq!(
877 LlmErrorKind::from_provider_status(401, "invalid x-api-key"),
878 LlmErrorKind::Authentication
879 );
880 assert_eq!(
881 LlmErrorKind::from_provider_status(403, "forbidden"),
882 LlmErrorKind::Authentication
883 );
884 assert_eq!(
885 LlmErrorKind::from_provider_status(429, "rate limit exceeded"),
886 LlmErrorKind::RateLimited
887 );
888 assert_eq!(
890 LlmErrorKind::from_provider_status(
891 429,
892 "{\"error\":{\"type\":\"insufficient_quota\"}}"
893 ),
894 LlmErrorKind::QuotaExhausted
895 );
896 assert_eq!(
898 LlmErrorKind::from_provider_status(
899 400,
900 "Your credit balance is too low to access the Anthropic API."
901 ),
902 LlmErrorKind::QuotaExhausted
903 );
904 assert_eq!(
905 LlmErrorKind::from_provider_status(529, "overloaded"),
906 LlmErrorKind::Unavailable
907 );
908 assert_eq!(
909 LlmErrorKind::from_provider_status(503, "unavailable"),
910 LlmErrorKind::Unavailable
911 );
912 assert_eq!(
913 LlmErrorKind::from_provider_status(400, "bad request"),
914 LlmErrorKind::InvalidRequest
915 );
916 }
917
918 #[test]
919 fn test_llm_error_kind_from_error_text_bedrock() {
920 assert_eq!(
921 LlmErrorKind::from_error_text("ThrottlingException: Too many requests"),
922 LlmErrorKind::RateLimited
923 );
924 assert_eq!(
925 LlmErrorKind::from_error_text("AccessDeniedException: not authorized"),
926 LlmErrorKind::Authentication
927 );
928 assert_eq!(
929 LlmErrorKind::from_error_text("ServiceUnavailableException"),
930 LlmErrorKind::Unavailable
931 );
932 assert_eq!(
933 LlmErrorKind::from_error_text("something else entirely"),
934 LlmErrorKind::Other
935 );
936 }
937
938 #[test]
939 fn test_user_facing_error_prefers_semantic_kind() {
940 let err = AgentLoopError::llm_kind(
943 LlmErrorKind::QuotaExhausted,
944 "OpenAI API error (429): insufficient_quota",
945 );
946 let user_error =
947 err.user_facing_error(UserFacingErrorContext::default().with_provider("openai"));
948 assert_eq!(
949 user_error.code,
950 user_facing_error_codes::PROVIDER_QUOTA_EXHAUSTED
951 );
952 assert_eq!(
953 user_error.fields.get("provider"),
954 Some(&serde_json::Value::String("openai".to_string()))
955 );
956
957 let err = AgentLoopError::llm_kind(LlmErrorKind::Authentication, "bad key");
958 assert_eq!(
959 err.user_facing_error(UserFacingErrorContext::default())
960 .code,
961 user_facing_error_codes::PROVIDER_MISCONFIGURED
962 );
963
964 let err = AgentLoopError::llm_kind(LlmErrorKind::RateLimited, "slow down");
965 let user_error =
966 err.user_facing_error(UserFacingErrorContext::default().with_retry_after(5));
967 assert_eq!(
968 user_error.code,
969 user_facing_error_codes::PROVIDER_RATE_LIMITED
970 );
971 assert_eq!(user_error.fields.get("retry_after"), Some(&json_val(&5)));
972
973 let err = AgentLoopError::llm_kind(LlmErrorKind::Unavailable, "overloaded");
974 assert_eq!(
975 err.user_facing_error(UserFacingErrorContext::default())
976 .code,
977 user_facing_error_codes::PROVIDER_UNAVAILABLE
978 );
979 }
980
981 #[test]
982 fn test_semantic_kind_drives_predicates() {
983 assert!(AgentLoopError::llm_kind(LlmErrorKind::RateLimited, "x").is_rate_limited());
984 assert!(AgentLoopError::llm_kind(LlmErrorKind::Authentication, "x").is_auth_error());
985 assert!(AgentLoopError::llm_kind(LlmErrorKind::Unavailable, "x").is_server_error());
986 assert!(AgentLoopError::llm("error (429)").is_rate_limited());
988 assert!(
989 !AgentLoopError::llm_kind(LlmErrorKind::Authentication, "error (429)")
990 .is_rate_limited()
991 );
992 }
993
994 #[test]
995 fn test_store_result_ext_ok() {
996 let result: std::result::Result<i32, String> = Ok(42);
997 assert_eq!(result.store_err().unwrap(), 42);
998 }
999
1000 #[test]
1001 fn test_store_result_ext_err() {
1002 let result: std::result::Result<i32, String> = Err("db error".to_string());
1003 let err = result.store_err().unwrap_err();
1004 assert!(matches!(err, AgentLoopError::MessageStore(_)));
1005 assert!(err.to_string().contains("db error"));
1006 }
1007
1008 #[test]
1009 fn test_json_val() {
1010 let v = json_val(&vec![1, 2, 3]);
1011 assert_eq!(v, serde_json::json!([1, 2, 3]));
1012 }
1013
1014 #[test]
1015 fn test_from_json() {
1016 let v = serde_json::json!(["a", "b"]);
1017 let result: Vec<String> = from_json(v);
1018 assert_eq!(result, vec!["a", "b"]);
1019 }
1020
1021 #[test]
1022 fn test_from_json_default_on_mismatch() {
1023 let v = serde_json::json!("not a number");
1024 let result: i32 = from_json(v);
1025 assert_eq!(result, 0);
1026 }
1027}