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, is_usage_limit_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"
46 | "billing_hard_limit_reached"
47 | "credit_balance_too_low"
48 | "credit_balance_exhausted" => Some(Self::QuotaExhausted),
49 "authentication_error" | "invalid_api_key" | "permission_denied" => {
50 Some(Self::Authentication)
51 }
52 "rate_limit_exceeded" | "rate_limit_error" | "overloaded_error" => {
53 Some(Self::RateLimited)
54 }
55 "server_error"
56 | "internal_error"
57 | "processing_error"
58 | "service_unavailable"
59 | "timeout" => Some(Self::Unavailable),
60 "invalid_request_error" | "model_not_found" => Some(Self::InvalidRequest),
61 _ => None,
62 }
63 }
64
65 pub fn from_provider_status(status: u16, body: &str) -> Self {
72 if is_provider_quota_message(body) || is_usage_limit_message(body) {
73 return LlmErrorKind::QuotaExhausted;
74 }
75 match status {
76 401 | 403 => LlmErrorKind::Authentication,
77 429 => LlmErrorKind::RateLimited,
78 408 | 409 => LlmErrorKind::Unavailable,
79 501 => LlmErrorKind::Other,
80 500..=599 => LlmErrorKind::Unavailable,
81 400..=499 => LlmErrorKind::InvalidRequest,
82 _ => LlmErrorKind::Other,
83 }
84 }
85
86 pub fn from_error_text(text: &str) -> Self {
89 if is_provider_quota_message(text) || is_usage_limit_message(text) {
90 return LlmErrorKind::QuotaExhausted;
91 }
92 let lower = text.to_ascii_lowercase();
93 if lower.contains("throttlingexception")
94 || lower.contains("toomanyrequestsexception")
95 || lower.contains("rate limit")
96 || lower.contains("too many requests")
97 {
98 return LlmErrorKind::RateLimited;
99 }
100 if lower.contains("accessdeniedexception")
101 || lower.contains("unrecognizedclientexception")
102 || lower.contains("expiredtokenexception")
103 || lower.contains("invalidsignatureexception")
104 || lower.contains("unauthorized")
105 {
106 return LlmErrorKind::Authentication;
107 }
108 if lower.contains("serviceunavailable")
109 || lower.contains("service unavailable")
110 || lower.contains("internalserverexception")
111 || lower.contains("modelnotreadyexception")
112 {
113 return LlmErrorKind::Unavailable;
114 }
115 LlmErrorKind::Other
116 }
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct LlmError {
122 pub kind: LlmErrorKind,
123 pub message: String,
124 #[serde(default)]
126 pub retry_attempts: u32,
127 #[serde(default)]
129 pub retry_wait_ms: u64,
130 #[serde(default)]
132 pub retry_handled: bool,
133}
134
135impl std::fmt::Display for LlmError {
136 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137 f.write_str(&self.message)
138 }
139}
140
141#[derive(Debug, Error)]
143pub enum AgentLoopError {
144 #[error("LLM error: {0}")]
146 Llm(LlmError),
147
148 #[error("Request too large: {0}")]
151 RequestTooLarge(String),
152
153 #[error("Model not available: {0}")]
156 ModelNotAvailable(String),
157
158 #[error("Tool execution error: {0}")]
160 ToolExecution(String),
161
162 #[error("Message store error: {0}")]
164 MessageStore(String),
165
166 #[error("Event emission error: {0}")]
168 EventEmission(String),
169
170 #[error("Configuration error: {0}")]
172 Configuration(String),
173
174 #[error("Max iterations ({0}) reached")]
176 MaxIterationsReached(usize),
177
178 #[error("Loop cancelled")]
180 Cancelled,
181
182 #[error("No messages to process")]
184 NoMessages,
185
186 #[error("Agent not found: {0}")]
188 AgentNotFound(AgentId),
189
190 #[error("Harness not found: {0}")]
192 HarnessNotFound(HarnessId),
193
194 #[error("Session not found: {0}")]
196 SessionNotFound(SessionId),
197
198 #[error("Internal error: {0}")]
200 Internal(#[from] anyhow::Error),
201
202 #[error(
204 "No driver registered for provider type '{0}'. Make sure the driver is registered at startup."
205 )]
206 DriverNotRegistered(String),
207}
208
209impl AgentLoopError {
210 pub fn with_provider(mut self, provider: &str) -> Self {
212 let prefix = format!("provider '{provider}': ");
213 match &mut self {
214 AgentLoopError::Llm(error) if !error.message.starts_with(&prefix) => {
215 error.message.insert_str(0, &prefix)
216 }
217 AgentLoopError::RequestTooLarge(message)
218 | AgentLoopError::ModelNotAvailable(message)
219 | AgentLoopError::Configuration(message)
220 if !message.starts_with(&prefix) =>
221 {
222 message.insert_str(0, &prefix)
223 }
224 _ => {}
225 }
226 self
227 }
228
229 pub fn llm(msg: impl Into<String>) -> Self {
232 AgentLoopError::Llm(LlmError {
233 kind: LlmErrorKind::Other,
234 message: msg.into(),
235 retry_attempts: 0,
236 retry_wait_ms: 0,
237 retry_handled: false,
238 })
239 }
240
241 pub fn llm_kind(kind: LlmErrorKind, msg: impl Into<String>) -> Self {
243 AgentLoopError::Llm(LlmError {
244 kind,
245 message: msg.into(),
246 retry_attempts: 0,
247 retry_wait_ms: 0,
248 retry_handled: false,
249 })
250 }
251
252 pub fn with_retry_metadata(mut self, metadata: &crate::llm_retry::RetryMetadata) -> Self {
255 if let AgentLoopError::Llm(error) = &mut self {
256 error.retry_attempts = metadata.attempts;
257 error.retry_wait_ms = metadata.total_retry_wait.as_millis() as u64;
258 error.retry_handled = true;
259 }
260 self
261 }
262
263 pub fn llm_retry_attempts(&self) -> u32 {
265 match self {
266 AgentLoopError::Llm(error) => error.retry_attempts,
267 _ => 0,
268 }
269 }
270
271 pub fn llm_retry_handled(&self) -> bool {
273 matches!(self, AgentLoopError::Llm(error) if error.retry_handled)
274 }
275
276 pub fn llm_error_kind(&self) -> Option<LlmErrorKind> {
278 match self {
279 AgentLoopError::Llm(err) => Some(err.kind),
280 _ => None,
281 }
282 }
283
284 pub fn tool(msg: impl Into<String>) -> Self {
286 AgentLoopError::ToolExecution(msg.into())
287 }
288
289 pub fn store(msg: impl Into<String>) -> Self {
291 AgentLoopError::MessageStore(msg.into())
292 }
293
294 pub fn event(msg: impl Into<String>) -> Self {
296 AgentLoopError::EventEmission(msg.into())
297 }
298
299 pub fn config(msg: impl Into<String>) -> Self {
301 AgentLoopError::Configuration(msg.into())
302 }
303
304 pub fn agent_not_found(agent_id: AgentId) -> Self {
306 AgentLoopError::AgentNotFound(agent_id)
307 }
308
309 pub fn harness_not_found(harness_id: HarnessId) -> Self {
311 AgentLoopError::HarnessNotFound(harness_id)
312 }
313
314 pub fn session_not_found(session_id: SessionId) -> Self {
316 AgentLoopError::SessionNotFound(session_id)
317 }
318
319 pub fn driver_not_registered(provider_type: impl Into<String>) -> Self {
321 AgentLoopError::DriverNotRegistered(provider_type.into())
322 }
323
324 pub fn request_too_large(msg: impl Into<String>) -> Self {
326 AgentLoopError::RequestTooLarge(msg.into())
327 }
328
329 pub fn model_not_available(model_id: impl Into<String>) -> Self {
331 AgentLoopError::ModelNotAvailable(model_id.into())
332 }
333
334 pub fn is_request_too_large(&self) -> bool {
336 matches!(self, AgentLoopError::RequestTooLarge(_))
337 }
338
339 pub fn is_model_not_available(&self) -> bool {
341 matches!(self, AgentLoopError::ModelNotAvailable(_))
342 }
343
344 pub fn model_not_available_id(&self) -> Option<&str> {
346 match self {
347 AgentLoopError::ModelNotAvailable(id) => Some(id),
348 _ => None,
349 }
350 }
351
352 pub fn is_rate_limited(&self) -> bool {
355 match self {
356 AgentLoopError::Llm(err) => match err.kind {
357 LlmErrorKind::RateLimited => true,
358 LlmErrorKind::Other => {
359 let msg_lower = err.message.to_ascii_lowercase();
360 msg_lower.contains("(429)")
361 || msg_lower.contains("rate limit")
362 || msg_lower.contains("too many requests")
363 }
364 _ => false,
365 },
366 _ => false,
367 }
368 }
369
370 pub fn is_auth_error(&self) -> bool {
372 match self {
373 AgentLoopError::Llm(err) => match err.kind {
374 LlmErrorKind::Authentication => true,
375 LlmErrorKind::Other => {
376 err.message.contains("(401)") || err.message.contains("(403)")
377 }
378 _ => false,
379 },
380 _ => false,
381 }
382 }
383
384 pub fn is_server_error(&self) -> bool {
386 match self {
387 AgentLoopError::Llm(err) => match err.kind {
388 LlmErrorKind::Unavailable => true,
389 LlmErrorKind::Other => {
390 let msg = &err.message;
391 msg.contains("(500)")
392 || msg.contains("(502)")
393 || msg.contains("(503)")
394 || msg.contains("(504)")
395 || msg.contains("(529)")
396 }
397 _ => false,
398 },
399 _ => false,
400 }
401 }
402
403 pub fn is_transient_llm_error(&self) -> bool {
408 match self {
409 AgentLoopError::Llm(err) => match err.kind {
410 LlmErrorKind::RateLimited | LlmErrorKind::Unavailable => true,
411 LlmErrorKind::Authentication
412 | LlmErrorKind::QuotaExhausted
413 | LlmErrorKind::InvalidRequest => false,
414 LlmErrorKind::Other => crate::llm_retry::is_transient_error_message(&err.message),
415 },
416 _ => false,
417 }
418 }
419
420 pub fn is_non_retryable(&self) -> bool {
431 match self {
432 AgentLoopError::AgentNotFound(_)
434 | AgentLoopError::HarnessNotFound(_)
435 | AgentLoopError::SessionNotFound(_)
436 | AgentLoopError::NoMessages => true,
437
438 AgentLoopError::Configuration(_) | AgentLoopError::DriverNotRegistered(_) => true,
440
441 AgentLoopError::MessageStore(msg) => msg.to_ascii_lowercase().contains("not found"),
443
444 _ => false,
446 }
447 }
448
449 pub fn user_facing_message(&self) -> String {
451 self.user_facing_error(UserFacingErrorContext::default())
452 .fallback_message()
453 }
454
455 pub fn user_facing_error(&self, context: UserFacingErrorContext) -> UserFacingError {
457 match self {
458 AgentLoopError::ModelNotAvailable(model_id) => {
459 UserFacingError::new(user_facing_error_codes::MODEL_UNAVAILABLE)
460 .with_field("model_id", model_id)
461 .with_optional_field("provider", context.provider)
462 }
463 AgentLoopError::RequestTooLarge(_) => {
464 UserFacingError::new(user_facing_error_codes::REQUEST_TOO_LARGE)
465 .with_optional_field("provider", context.provider)
466 .with_optional_field("model_id", context.model_id)
467 }
468 AgentLoopError::MaxIterationsReached(max_iterations) => {
469 UserFacingError::new(user_facing_error_codes::MAX_ITERATIONS)
470 .with_field("max_iterations", max_iterations)
471 }
472 AgentLoopError::Llm(err) => {
473 let code = match err.kind {
477 LlmErrorKind::Authentication => {
478 Some(user_facing_error_codes::PROVIDER_MISCONFIGURED)
479 }
480 LlmErrorKind::QuotaExhausted => {
481 Some(user_facing_error_codes::PROVIDER_QUOTA_EXHAUSTED)
482 }
483 LlmErrorKind::RateLimited => {
484 Some(user_facing_error_codes::PROVIDER_RATE_LIMITED)
485 }
486 LlmErrorKind::Unavailable => {
487 Some(user_facing_error_codes::PROVIDER_UNAVAILABLE)
488 }
489 LlmErrorKind::InvalidRequest | LlmErrorKind::Other => None,
490 };
491 match code {
492 Some(code) => {
493 let error = UserFacingError::new(code)
494 .with_optional_field("provider", context.provider)
495 .with_optional_field("model_id", context.model_id);
496 if code == user_facing_error_codes::PROVIDER_RATE_LIMITED {
497 error.with_optional_field("retry_after", context.retry_after)
498 } else {
499 error
500 }
501 }
502 None => classify_runtime_error_message(&err.message, &context),
503 }
504 }
505 _ => UserFacingError::new(user_facing_error_codes::PROCESSING_ERROR)
506 .with_optional_field("provider", context.provider)
507 .with_optional_field("model_id", context.model_id),
508 }
509 }
510}
511
512pub trait StoreResultExt<T> {
528 fn store_err(self) -> Result<T>;
529}
530
531impl<T, E: std::fmt::Display> StoreResultExt<T> for std::result::Result<T, E> {
532 fn store_err(self) -> Result<T> {
533 self.map_err(|e| AgentLoopError::store(e.to_string()))
534 }
535}
536
537#[derive(Debug, Clone, Copy, PartialEq, Eq)]
557pub enum FileSystemErrorClass {
558 NotFound,
560 ReadOnly,
562 IsADirectory,
564 NotADirectory,
566 NotEmpty,
568 Other,
570}
571
572#[derive(Debug, Error)]
577pub enum FileSystemError {
578 #[error("{0}")]
579 NotFound(String),
580 #[error("{0}")]
581 ReadOnly(String),
582 #[error("{0}")]
583 IsADirectory(String),
584 #[error("{0}")]
585 NotADirectory(String),
586 #[error("{0}")]
587 NotEmpty(String),
588}
589
590impl FileSystemError {
591 fn class(&self) -> FileSystemErrorClass {
592 match self {
593 FileSystemError::NotFound(_) => FileSystemErrorClass::NotFound,
594 FileSystemError::ReadOnly(_) => FileSystemErrorClass::ReadOnly,
595 FileSystemError::IsADirectory(_) => FileSystemErrorClass::IsADirectory,
596 FileSystemError::NotADirectory(_) => FileSystemErrorClass::NotADirectory,
597 FileSystemError::NotEmpty(_) => FileSystemErrorClass::NotEmpty,
598 }
599 }
600}
601
602pub fn classify_fs_error<E>(err: &E) -> FileSystemErrorClass
612where
613 E: std::error::Error + 'static,
614{
615 let mut source: Option<&(dyn std::error::Error + 'static)> = Some(err);
620 while let Some(current) = source {
621 if let Some(typed) = current.downcast_ref::<FileSystemError>() {
622 return typed.class();
623 }
624 source = current.source();
625 }
626
627 let msg = err.to_string();
628 if msg.contains("readonly") {
632 FileSystemErrorClass::ReadOnly
633 } else if msg.contains("is a directory") {
634 FileSystemErrorClass::IsADirectory
635 } else if msg.contains("not a directory") {
636 FileSystemErrorClass::NotADirectory
637 } else if msg.contains("not empty") || msg.contains("recursive") {
638 FileSystemErrorClass::NotEmpty
639 } else if msg.contains("not found") {
640 FileSystemErrorClass::NotFound
641 } else {
642 FileSystemErrorClass::Other
643 }
644}
645
646pub fn json_val<T: Serialize>(value: &T) -> serde_json::Value {
657 serde_json::to_value(value).unwrap_or_default()
658}
659
660pub fn from_json<T: DeserializeOwned + Default>(value: serde_json::Value) -> T {
667 serde_json::from_value(value).unwrap_or_default()
668}
669
670#[cfg(test)]
671mod tests {
672 use super::*;
673
674 #[test]
678 fn classify_fs_error_prefers_typed_variant() {
679 let err = FileSystemError::ReadOnly("x".into());
680 assert_eq!(classify_fs_error(&err), FileSystemErrorClass::ReadOnly);
681 let err = FileSystemError::IsADirectory("x".into());
682 assert_eq!(classify_fs_error(&err), FileSystemErrorClass::IsADirectory);
683 }
684
685 #[test]
686 fn classify_fs_error_substring_fallback_matches_real_producers() {
687 let cases = [
690 (
691 "Cannot modify readonly file: /a",
692 FileSystemErrorClass::ReadOnly,
693 ),
694 (
695 "Cannot delete readonly file: /a",
696 FileSystemErrorClass::ReadOnly,
697 ),
698 (
699 "write target is a directory: /a",
700 FileSystemErrorClass::IsADirectory,
701 ),
702 (
703 "Path is not a directory: /a",
704 FileSystemErrorClass::NotADirectory,
705 ),
706 (
707 "workspace root is not a directory: /a",
708 FileSystemErrorClass::NotADirectory,
709 ),
710 ("Directory not found: /a", FileSystemErrorClass::NotFound),
711 (
712 "Directory is not empty. Use recursive=true to delete",
713 FileSystemErrorClass::NotEmpty,
714 ),
715 (
716 "Cannot delete root directory without recursive flag",
717 FileSystemErrorClass::NotEmpty,
718 ),
719 (
720 "recursive delete failed for /a: io",
721 FileSystemErrorClass::NotEmpty,
722 ),
723 ("disk full", FileSystemErrorClass::Other),
724 ];
725 for (msg, expected) in cases {
726 let err = AgentLoopError::store(msg);
727 assert_eq!(classify_fs_error(&err), expected, "msg: {msg}");
728 }
729 }
730
731 #[test]
734 fn classify_fs_error_classifies_typed_directly() {
735 let err = FileSystemError::NotEmpty("anything at all".into());
736 assert_eq!(classify_fs_error(&err), FileSystemErrorClass::NotEmpty);
737 }
738
739 #[test]
742 fn classify_fs_error_does_not_match_hyphenated_read_only() {
743 let err = AgentLoopError::store("file is read-only: /a");
744 assert_eq!(classify_fs_error(&err), FileSystemErrorClass::Other);
745 }
746
747 #[test]
748 fn test_is_request_too_large_returns_true_for_typed_error() {
749 let err = AgentLoopError::request_too_large("context length exceeded");
750 assert!(err.is_request_too_large());
751 }
752
753 #[test]
754 fn test_is_request_too_large_returns_false_for_llm_error() {
755 let err = AgentLoopError::llm("OpenAI API error (500): Internal server error");
756 assert!(!err.is_request_too_large());
757 }
758
759 #[test]
760 fn test_is_request_too_large_returns_false_for_other_errors() {
761 let err = AgentLoopError::ToolExecution("some error".to_string());
762 assert!(!err.is_request_too_large());
763
764 let err = AgentLoopError::Cancelled;
765 assert!(!err.is_request_too_large());
766 }
767
768 #[test]
769 fn test_request_too_large_error_preserves_message() {
770 let original_msg = "OpenAI API error (429): Request too large for gpt-4";
771 let err = AgentLoopError::request_too_large(original_msg);
772 assert_eq!(
773 err.to_string(),
774 format!("Request too large: {}", original_msg)
775 );
776 }
777
778 #[test]
779 fn test_is_model_not_available_returns_true_for_typed_error() {
780 let err = AgentLoopError::model_not_available("claude-sonnet-4-6-20260217");
781 assert!(err.is_model_not_available());
782 assert_eq!(
783 err.model_not_available_id(),
784 Some("claude-sonnet-4-6-20260217")
785 );
786 }
787
788 #[test]
789 fn test_is_model_not_available_returns_false_for_llm_error() {
790 let err = AgentLoopError::llm("some error");
791 assert!(!err.is_model_not_available());
792 assert_eq!(err.model_not_available_id(), None);
793 }
794
795 #[test]
796 fn test_model_not_available_error_display() {
797 let err = AgentLoopError::model_not_available("gpt-99");
798 assert_eq!(err.to_string(), "Model not available: gpt-99");
799 }
800
801 #[test]
802 fn test_is_rate_limited_detects_429() {
803 let err = AgentLoopError::llm("Anthropic API error (429): rate limit exceeded");
804 assert!(err.is_rate_limited());
805 }
806
807 #[test]
808 fn test_is_rate_limited_detects_rate_limit_keyword() {
809 let err =
810 AgentLoopError::llm("Rate limit exceeded (after 2 retries, last error: too many)");
811 assert!(err.is_rate_limited());
812 }
813
814 #[test]
815 fn test_is_rate_limited_false_for_server_error() {
816 let err = AgentLoopError::llm("Anthropic API error (500): internal server error");
817 assert!(!err.is_rate_limited());
818 }
819
820 #[test]
821 fn test_is_auth_error_detects_401() {
822 let err = AgentLoopError::llm("Anthropic API error (401): invalid api key");
823 assert!(err.is_auth_error());
824 }
825
826 #[test]
827 fn test_is_auth_error_detects_403() {
828 let err = AgentLoopError::llm("OpenAI API error (403): forbidden");
829 assert!(err.is_auth_error());
830 }
831
832 #[test]
833 fn test_is_server_error_detects_500() {
834 let err = AgentLoopError::llm("Anthropic API error (500): internal server error");
835 assert!(err.is_server_error());
836 }
837
838 #[test]
839 fn test_is_server_error_detects_503() {
840 let err = AgentLoopError::llm("OpenAI API error (503): service unavailable");
841 assert!(err.is_server_error());
842 }
843
844 #[test]
845 fn test_user_facing_message_rate_limited() {
846 let err = AgentLoopError::llm("Anthropic API error (429): rate limit exceeded");
847 assert_eq!(
848 err.user_facing_message(),
849 "Rate limited by the AI provider. Please wait a moment."
850 );
851 }
852
853 #[test]
854 fn test_user_facing_message_auth_error() {
855 let err = AgentLoopError::llm("Anthropic API error (401): invalid api key");
856 assert_eq!(
857 err.user_facing_message(),
858 "There is a misconfiguration with the AI provider. Please contact support."
859 );
860 }
861
862 #[test]
863 fn test_user_facing_message_server_error() {
864 let err = AgentLoopError::llm("Anthropic API error (500): internal server error");
865 assert_eq!(
866 err.user_facing_message(),
867 "The AI provider is experiencing issues. Please try again shortly."
868 );
869 }
870
871 #[test]
872 fn test_user_facing_message_generic_fallback() {
873 let err = AgentLoopError::llm("Failed to send request: connection refused");
874 assert_eq!(
875 err.user_facing_message(),
876 "I encountered an error while processing your request. Please try again later."
877 );
878 }
879
880 #[test]
881 fn test_user_facing_message_model_not_available() {
882 let err = AgentLoopError::model_not_available("gpt-99");
883 assert!(err.user_facing_message().contains("gpt-99"));
884 assert!(err.user_facing_message().contains("not available"));
885 }
886
887 #[test]
888 fn test_user_facing_message_request_too_large() {
889 let err = AgentLoopError::request_too_large("context length exceeded");
890 assert!(err.user_facing_message().contains("too long"));
891 }
892
893 #[test]
894 fn test_user_facing_error_model_not_available_includes_model_id() {
895 let err = AgentLoopError::model_not_available("gpt-99");
896 let user_error = err.user_facing_error(UserFacingErrorContext::default());
897
898 assert_eq!(user_error.code, user_facing_error_codes::MODEL_UNAVAILABLE);
899 assert_eq!(
900 user_error.fields.get("model_id"),
901 Some(&serde_json::Value::String("gpt-99".to_string()))
902 );
903 }
904
905 #[test]
906 fn test_user_facing_error_rate_limited_includes_provider_context() {
907 let err = AgentLoopError::llm("Anthropic API error (429): rate limit exceeded");
908 let user_error = err.user_facing_error(
909 UserFacingErrorContext::default()
910 .with_provider("anthropic")
911 .with_model_id("claude-sonnet-4-5")
912 .with_retry_after(12),
913 );
914
915 assert_eq!(
916 user_error.code,
917 user_facing_error_codes::PROVIDER_RATE_LIMITED
918 );
919 assert_eq!(
920 user_error.fields.get("provider"),
921 Some(&serde_json::Value::String("anthropic".to_string()))
922 );
923 assert_eq!(
924 user_error.fields.get("model_id"),
925 Some(&serde_json::Value::String("claude-sonnet-4-5".to_string()))
926 );
927 assert_eq!(
928 user_error.fields.get("retry_after"),
929 Some(&serde_json::json!(12))
930 );
931 }
932
933 #[test]
934 fn test_llm_error_kind_from_provider_status() {
935 assert_eq!(
936 LlmErrorKind::from_provider_status(401, "invalid x-api-key"),
937 LlmErrorKind::Authentication
938 );
939 assert_eq!(
940 LlmErrorKind::from_provider_status(403, "forbidden"),
941 LlmErrorKind::Authentication
942 );
943 assert_eq!(
944 LlmErrorKind::from_provider_status(429, "rate limit exceeded"),
945 LlmErrorKind::RateLimited
946 );
947 assert_eq!(
949 LlmErrorKind::from_provider_status(
950 429,
951 "{\"error\":{\"type\":\"insufficient_quota\"}}"
952 ),
953 LlmErrorKind::QuotaExhausted
954 );
955 assert_eq!(
956 LlmErrorKind::from_provider_status(
957 429,
958 "{\"error\":{\"code\":\"credit_balance_exhausted\"}}"
959 ),
960 LlmErrorKind::QuotaExhausted
961 );
962 assert_eq!(
963 LlmErrorKind::from_provider_status(
964 429,
965 "{\"error\":{\"type\":\"usage_limit_reached\"}}"
966 ),
967 LlmErrorKind::QuotaExhausted
968 );
969 assert_eq!(
971 LlmErrorKind::from_provider_status(
972 400,
973 "Your credit balance is too low to access the Anthropic API."
974 ),
975 LlmErrorKind::QuotaExhausted
976 );
977 assert_eq!(
978 LlmErrorKind::from_provider_status(529, "overloaded"),
979 LlmErrorKind::Unavailable
980 );
981 assert_eq!(
982 LlmErrorKind::from_provider_status(503, "unavailable"),
983 LlmErrorKind::Unavailable
984 );
985 assert_eq!(
986 LlmErrorKind::from_provider_status(400, "bad request"),
987 LlmErrorKind::InvalidRequest
988 );
989 }
990
991 #[test]
992 fn test_llm_error_kind_from_error_text_bedrock() {
993 assert_eq!(
994 LlmErrorKind::from_error_text("ThrottlingException: Too many requests"),
995 LlmErrorKind::RateLimited
996 );
997 assert_eq!(
998 LlmErrorKind::from_error_text("AccessDeniedException: not authorized"),
999 LlmErrorKind::Authentication
1000 );
1001 assert_eq!(
1002 LlmErrorKind::from_error_text("ServiceUnavailableException"),
1003 LlmErrorKind::Unavailable
1004 );
1005 assert_eq!(
1006 LlmErrorKind::from_error_text("usage_limit_reached; resets_at=1783767823"),
1007 LlmErrorKind::QuotaExhausted
1008 );
1009 assert_eq!(
1010 LlmErrorKind::from_error_text("something else entirely"),
1011 LlmErrorKind::Other
1012 );
1013 }
1014
1015 #[test]
1016 fn test_user_facing_error_prefers_semantic_kind() {
1017 let err = AgentLoopError::llm_kind(
1020 LlmErrorKind::QuotaExhausted,
1021 "OpenAI API error (429): insufficient_quota",
1022 );
1023 let user_error =
1024 err.user_facing_error(UserFacingErrorContext::default().with_provider("openai"));
1025 assert_eq!(
1026 user_error.code,
1027 user_facing_error_codes::PROVIDER_QUOTA_EXHAUSTED
1028 );
1029 assert_eq!(
1030 user_error.fields.get("provider"),
1031 Some(&serde_json::Value::String("openai".to_string()))
1032 );
1033
1034 let err = AgentLoopError::llm_kind(LlmErrorKind::Authentication, "bad key");
1035 assert_eq!(
1036 err.user_facing_error(UserFacingErrorContext::default())
1037 .code,
1038 user_facing_error_codes::PROVIDER_MISCONFIGURED
1039 );
1040
1041 let err = AgentLoopError::llm_kind(LlmErrorKind::RateLimited, "slow down");
1042 let user_error =
1043 err.user_facing_error(UserFacingErrorContext::default().with_retry_after(5));
1044 assert_eq!(
1045 user_error.code,
1046 user_facing_error_codes::PROVIDER_RATE_LIMITED
1047 );
1048 assert_eq!(user_error.fields.get("retry_after"), Some(&json_val(&5)));
1049
1050 let err = AgentLoopError::llm_kind(LlmErrorKind::Unavailable, "overloaded");
1051 assert_eq!(
1052 err.user_facing_error(UserFacingErrorContext::default())
1053 .code,
1054 user_facing_error_codes::PROVIDER_UNAVAILABLE
1055 );
1056 }
1057
1058 #[test]
1059 fn test_semantic_kind_drives_predicates() {
1060 assert!(AgentLoopError::llm_kind(LlmErrorKind::RateLimited, "x").is_rate_limited());
1061 assert!(AgentLoopError::llm_kind(LlmErrorKind::Authentication, "x").is_auth_error());
1062 assert!(AgentLoopError::llm_kind(LlmErrorKind::Unavailable, "x").is_server_error());
1063 assert!(AgentLoopError::llm("error (429)").is_rate_limited());
1065 assert!(
1066 !AgentLoopError::llm_kind(LlmErrorKind::Authentication, "error (429)")
1067 .is_rate_limited()
1068 );
1069 }
1070
1071 #[test]
1072 fn test_store_result_ext_ok() {
1073 let result: std::result::Result<i32, String> = Ok(42);
1074 assert_eq!(result.store_err().unwrap(), 42);
1075 }
1076
1077 #[test]
1078 fn test_store_result_ext_err() {
1079 let result: std::result::Result<i32, String> = Err("db error".to_string());
1080 let err = result.store_err().unwrap_err();
1081 assert!(matches!(err, AgentLoopError::MessageStore(_)));
1082 assert!(err.to_string().contains("db error"));
1083 }
1084
1085 #[test]
1086 fn test_json_val() {
1087 let v = json_val(&vec![1, 2, 3]);
1088 assert_eq!(v, serde_json::json!([1, 2, 3]));
1089 }
1090
1091 #[test]
1092 fn test_from_json() {
1093 let v = serde_json::json!(["a", "b"]);
1094 let result: Vec<String> = from_json(v);
1095 assert_eq!(result, vec!["a", "b"]);
1096 }
1097
1098 #[test]
1099 fn test_from_json_default_on_mismatch() {
1100 let v = serde_json::json!("not a number");
1101 let result: i32 = from_json(v);
1102 assert_eq!(result, 0);
1103 }
1104}