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 llm(msg: impl Into<String>) -> Self {
213 AgentLoopError::Llm(LlmError {
214 kind: LlmErrorKind::Other,
215 message: msg.into(),
216 retry_attempts: 0,
217 retry_wait_ms: 0,
218 retry_handled: false,
219 })
220 }
221
222 pub fn llm_kind(kind: LlmErrorKind, msg: impl Into<String>) -> Self {
224 AgentLoopError::Llm(LlmError {
225 kind,
226 message: msg.into(),
227 retry_attempts: 0,
228 retry_wait_ms: 0,
229 retry_handled: false,
230 })
231 }
232
233 pub fn with_retry_metadata(mut self, metadata: &crate::llm_retry::RetryMetadata) -> Self {
236 if let AgentLoopError::Llm(error) = &mut self {
237 error.retry_attempts = metadata.attempts;
238 error.retry_wait_ms = metadata.total_retry_wait.as_millis() as u64;
239 error.retry_handled = true;
240 }
241 self
242 }
243
244 pub fn llm_retry_attempts(&self) -> u32 {
246 match self {
247 AgentLoopError::Llm(error) => error.retry_attempts,
248 _ => 0,
249 }
250 }
251
252 pub fn llm_retry_handled(&self) -> bool {
254 matches!(self, AgentLoopError::Llm(error) if error.retry_handled)
255 }
256
257 pub fn llm_error_kind(&self) -> Option<LlmErrorKind> {
259 match self {
260 AgentLoopError::Llm(err) => Some(err.kind),
261 _ => None,
262 }
263 }
264
265 pub fn tool(msg: impl Into<String>) -> Self {
267 AgentLoopError::ToolExecution(msg.into())
268 }
269
270 pub fn store(msg: impl Into<String>) -> Self {
272 AgentLoopError::MessageStore(msg.into())
273 }
274
275 pub fn event(msg: impl Into<String>) -> Self {
277 AgentLoopError::EventEmission(msg.into())
278 }
279
280 pub fn config(msg: impl Into<String>) -> Self {
282 AgentLoopError::Configuration(msg.into())
283 }
284
285 pub fn agent_not_found(agent_id: AgentId) -> Self {
287 AgentLoopError::AgentNotFound(agent_id)
288 }
289
290 pub fn harness_not_found(harness_id: HarnessId) -> Self {
292 AgentLoopError::HarnessNotFound(harness_id)
293 }
294
295 pub fn session_not_found(session_id: SessionId) -> Self {
297 AgentLoopError::SessionNotFound(session_id)
298 }
299
300 pub fn driver_not_registered(provider_type: impl Into<String>) -> Self {
302 AgentLoopError::DriverNotRegistered(provider_type.into())
303 }
304
305 pub fn request_too_large(msg: impl Into<String>) -> Self {
307 AgentLoopError::RequestTooLarge(msg.into())
308 }
309
310 pub fn model_not_available(model_id: impl Into<String>) -> Self {
312 AgentLoopError::ModelNotAvailable(model_id.into())
313 }
314
315 pub fn is_request_too_large(&self) -> bool {
317 matches!(self, AgentLoopError::RequestTooLarge(_))
318 }
319
320 pub fn is_model_not_available(&self) -> bool {
322 matches!(self, AgentLoopError::ModelNotAvailable(_))
323 }
324
325 pub fn model_not_available_id(&self) -> Option<&str> {
327 match self {
328 AgentLoopError::ModelNotAvailable(id) => Some(id),
329 _ => None,
330 }
331 }
332
333 pub fn is_rate_limited(&self) -> bool {
336 match self {
337 AgentLoopError::Llm(err) => match err.kind {
338 LlmErrorKind::RateLimited => true,
339 LlmErrorKind::Other => {
340 let msg_lower = err.message.to_ascii_lowercase();
341 msg_lower.contains("(429)")
342 || msg_lower.contains("rate limit")
343 || msg_lower.contains("too many requests")
344 }
345 _ => false,
346 },
347 _ => false,
348 }
349 }
350
351 pub fn is_auth_error(&self) -> bool {
353 match self {
354 AgentLoopError::Llm(err) => match err.kind {
355 LlmErrorKind::Authentication => true,
356 LlmErrorKind::Other => {
357 err.message.contains("(401)") || err.message.contains("(403)")
358 }
359 _ => false,
360 },
361 _ => false,
362 }
363 }
364
365 pub fn is_server_error(&self) -> bool {
367 match self {
368 AgentLoopError::Llm(err) => match err.kind {
369 LlmErrorKind::Unavailable => true,
370 LlmErrorKind::Other => {
371 let msg = &err.message;
372 msg.contains("(500)")
373 || msg.contains("(502)")
374 || msg.contains("(503)")
375 || msg.contains("(504)")
376 || msg.contains("(529)")
377 }
378 _ => false,
379 },
380 _ => false,
381 }
382 }
383
384 pub fn is_transient_llm_error(&self) -> bool {
389 match self {
390 AgentLoopError::Llm(err) => match err.kind {
391 LlmErrorKind::RateLimited | LlmErrorKind::Unavailable => true,
392 LlmErrorKind::Authentication
393 | LlmErrorKind::QuotaExhausted
394 | LlmErrorKind::InvalidRequest => false,
395 LlmErrorKind::Other => crate::llm_retry::is_transient_error_message(&err.message),
396 },
397 _ => false,
398 }
399 }
400
401 pub fn is_non_retryable(&self) -> bool {
412 match self {
413 AgentLoopError::AgentNotFound(_)
415 | AgentLoopError::HarnessNotFound(_)
416 | AgentLoopError::SessionNotFound(_)
417 | AgentLoopError::NoMessages => true,
418
419 AgentLoopError::Configuration(_) | AgentLoopError::DriverNotRegistered(_) => true,
421
422 AgentLoopError::MessageStore(msg) => msg.to_ascii_lowercase().contains("not found"),
424
425 _ => false,
427 }
428 }
429
430 pub fn user_facing_message(&self) -> String {
432 self.user_facing_error(UserFacingErrorContext::default())
433 .fallback_message()
434 }
435
436 pub fn user_facing_error(&self, context: UserFacingErrorContext) -> UserFacingError {
438 match self {
439 AgentLoopError::ModelNotAvailable(model_id) => {
440 UserFacingError::new(user_facing_error_codes::MODEL_UNAVAILABLE)
441 .with_field("model_id", model_id)
442 .with_optional_field("provider", context.provider)
443 }
444 AgentLoopError::RequestTooLarge(_) => {
445 UserFacingError::new(user_facing_error_codes::REQUEST_TOO_LARGE)
446 .with_optional_field("provider", context.provider)
447 .with_optional_field("model_id", context.model_id)
448 }
449 AgentLoopError::MaxIterationsReached(max_iterations) => {
450 UserFacingError::new(user_facing_error_codes::MAX_ITERATIONS)
451 .with_field("max_iterations", max_iterations)
452 }
453 AgentLoopError::Llm(err) => {
454 let code = match err.kind {
458 LlmErrorKind::Authentication => {
459 Some(user_facing_error_codes::PROVIDER_MISCONFIGURED)
460 }
461 LlmErrorKind::QuotaExhausted => {
462 Some(user_facing_error_codes::PROVIDER_QUOTA_EXHAUSTED)
463 }
464 LlmErrorKind::RateLimited => {
465 Some(user_facing_error_codes::PROVIDER_RATE_LIMITED)
466 }
467 LlmErrorKind::Unavailable => {
468 Some(user_facing_error_codes::PROVIDER_UNAVAILABLE)
469 }
470 LlmErrorKind::InvalidRequest | LlmErrorKind::Other => None,
471 };
472 match code {
473 Some(code) => {
474 let error = UserFacingError::new(code)
475 .with_optional_field("provider", context.provider)
476 .with_optional_field("model_id", context.model_id);
477 if code == user_facing_error_codes::PROVIDER_RATE_LIMITED {
478 error.with_optional_field("retry_after", context.retry_after)
479 } else {
480 error
481 }
482 }
483 None => classify_runtime_error_message(&err.message, &context),
484 }
485 }
486 _ => UserFacingError::new(user_facing_error_codes::PROCESSING_ERROR)
487 .with_optional_field("provider", context.provider)
488 .with_optional_field("model_id", context.model_id),
489 }
490 }
491}
492
493pub trait StoreResultExt<T> {
509 fn store_err(self) -> Result<T>;
510}
511
512impl<T, E: std::fmt::Display> StoreResultExt<T> for std::result::Result<T, E> {
513 fn store_err(self) -> Result<T> {
514 self.map_err(|e| AgentLoopError::store(e.to_string()))
515 }
516}
517
518#[derive(Debug, Clone, Copy, PartialEq, Eq)]
538pub enum FileSystemErrorClass {
539 NotFound,
541 ReadOnly,
543 IsADirectory,
545 NotADirectory,
547 NotEmpty,
549 Other,
551}
552
553#[derive(Debug, Error)]
558pub enum FileSystemError {
559 #[error("{0}")]
560 NotFound(String),
561 #[error("{0}")]
562 ReadOnly(String),
563 #[error("{0}")]
564 IsADirectory(String),
565 #[error("{0}")]
566 NotADirectory(String),
567 #[error("{0}")]
568 NotEmpty(String),
569}
570
571impl FileSystemError {
572 fn class(&self) -> FileSystemErrorClass {
573 match self {
574 FileSystemError::NotFound(_) => FileSystemErrorClass::NotFound,
575 FileSystemError::ReadOnly(_) => FileSystemErrorClass::ReadOnly,
576 FileSystemError::IsADirectory(_) => FileSystemErrorClass::IsADirectory,
577 FileSystemError::NotADirectory(_) => FileSystemErrorClass::NotADirectory,
578 FileSystemError::NotEmpty(_) => FileSystemErrorClass::NotEmpty,
579 }
580 }
581}
582
583pub fn classify_fs_error<E>(err: &E) -> FileSystemErrorClass
593where
594 E: std::error::Error + 'static,
595{
596 let mut source: Option<&(dyn std::error::Error + 'static)> = Some(err);
601 while let Some(current) = source {
602 if let Some(typed) = current.downcast_ref::<FileSystemError>() {
603 return typed.class();
604 }
605 source = current.source();
606 }
607
608 let msg = err.to_string();
609 if msg.contains("readonly") {
613 FileSystemErrorClass::ReadOnly
614 } else if msg.contains("is a directory") {
615 FileSystemErrorClass::IsADirectory
616 } else if msg.contains("not a directory") {
617 FileSystemErrorClass::NotADirectory
618 } else if msg.contains("not empty") || msg.contains("recursive") {
619 FileSystemErrorClass::NotEmpty
620 } else if msg.contains("not found") {
621 FileSystemErrorClass::NotFound
622 } else {
623 FileSystemErrorClass::Other
624 }
625}
626
627pub fn json_val<T: Serialize>(value: &T) -> serde_json::Value {
638 serde_json::to_value(value).unwrap_or_default()
639}
640
641pub fn from_json<T: DeserializeOwned + Default>(value: serde_json::Value) -> T {
648 serde_json::from_value(value).unwrap_or_default()
649}
650
651#[cfg(test)]
652mod tests {
653 use super::*;
654
655 #[test]
659 fn classify_fs_error_prefers_typed_variant() {
660 let err = FileSystemError::ReadOnly("x".into());
661 assert_eq!(classify_fs_error(&err), FileSystemErrorClass::ReadOnly);
662 let err = FileSystemError::IsADirectory("x".into());
663 assert_eq!(classify_fs_error(&err), FileSystemErrorClass::IsADirectory);
664 }
665
666 #[test]
667 fn classify_fs_error_substring_fallback_matches_real_producers() {
668 let cases = [
671 (
672 "Cannot modify readonly file: /a",
673 FileSystemErrorClass::ReadOnly,
674 ),
675 (
676 "Cannot delete readonly file: /a",
677 FileSystemErrorClass::ReadOnly,
678 ),
679 (
680 "write target is a directory: /a",
681 FileSystemErrorClass::IsADirectory,
682 ),
683 (
684 "Path is not a directory: /a",
685 FileSystemErrorClass::NotADirectory,
686 ),
687 (
688 "workspace root is not a directory: /a",
689 FileSystemErrorClass::NotADirectory,
690 ),
691 ("Directory not found: /a", FileSystemErrorClass::NotFound),
692 (
693 "Directory is not empty. Use recursive=true to delete",
694 FileSystemErrorClass::NotEmpty,
695 ),
696 (
697 "Cannot delete root directory without recursive flag",
698 FileSystemErrorClass::NotEmpty,
699 ),
700 (
701 "recursive delete failed for /a: io",
702 FileSystemErrorClass::NotEmpty,
703 ),
704 ("disk full", FileSystemErrorClass::Other),
705 ];
706 for (msg, expected) in cases {
707 let err = AgentLoopError::store(msg);
708 assert_eq!(classify_fs_error(&err), expected, "msg: {msg}");
709 }
710 }
711
712 #[test]
715 fn classify_fs_error_classifies_typed_directly() {
716 let err = FileSystemError::NotEmpty("anything at all".into());
717 assert_eq!(classify_fs_error(&err), FileSystemErrorClass::NotEmpty);
718 }
719
720 #[test]
723 fn classify_fs_error_does_not_match_hyphenated_read_only() {
724 let err = AgentLoopError::store("file is read-only: /a");
725 assert_eq!(classify_fs_error(&err), FileSystemErrorClass::Other);
726 }
727
728 #[test]
729 fn test_is_request_too_large_returns_true_for_typed_error() {
730 let err = AgentLoopError::request_too_large("context length exceeded");
731 assert!(err.is_request_too_large());
732 }
733
734 #[test]
735 fn test_is_request_too_large_returns_false_for_llm_error() {
736 let err = AgentLoopError::llm("OpenAI API error (500): Internal server error");
737 assert!(!err.is_request_too_large());
738 }
739
740 #[test]
741 fn test_is_request_too_large_returns_false_for_other_errors() {
742 let err = AgentLoopError::ToolExecution("some error".to_string());
743 assert!(!err.is_request_too_large());
744
745 let err = AgentLoopError::Cancelled;
746 assert!(!err.is_request_too_large());
747 }
748
749 #[test]
750 fn test_request_too_large_error_preserves_message() {
751 let original_msg = "OpenAI API error (429): Request too large for gpt-4";
752 let err = AgentLoopError::request_too_large(original_msg);
753 assert_eq!(
754 err.to_string(),
755 format!("Request too large: {}", original_msg)
756 );
757 }
758
759 #[test]
760 fn test_is_model_not_available_returns_true_for_typed_error() {
761 let err = AgentLoopError::model_not_available("claude-sonnet-4-6-20260217");
762 assert!(err.is_model_not_available());
763 assert_eq!(
764 err.model_not_available_id(),
765 Some("claude-sonnet-4-6-20260217")
766 );
767 }
768
769 #[test]
770 fn test_is_model_not_available_returns_false_for_llm_error() {
771 let err = AgentLoopError::llm("some error");
772 assert!(!err.is_model_not_available());
773 assert_eq!(err.model_not_available_id(), None);
774 }
775
776 #[test]
777 fn test_model_not_available_error_display() {
778 let err = AgentLoopError::model_not_available("gpt-99");
779 assert_eq!(err.to_string(), "Model not available: gpt-99");
780 }
781
782 #[test]
783 fn test_is_rate_limited_detects_429() {
784 let err = AgentLoopError::llm("Anthropic API error (429): rate limit exceeded");
785 assert!(err.is_rate_limited());
786 }
787
788 #[test]
789 fn test_is_rate_limited_detects_rate_limit_keyword() {
790 let err =
791 AgentLoopError::llm("Rate limit exceeded (after 2 retries, last error: too many)");
792 assert!(err.is_rate_limited());
793 }
794
795 #[test]
796 fn test_is_rate_limited_false_for_server_error() {
797 let err = AgentLoopError::llm("Anthropic API error (500): internal server error");
798 assert!(!err.is_rate_limited());
799 }
800
801 #[test]
802 fn test_is_auth_error_detects_401() {
803 let err = AgentLoopError::llm("Anthropic API error (401): invalid api key");
804 assert!(err.is_auth_error());
805 }
806
807 #[test]
808 fn test_is_auth_error_detects_403() {
809 let err = AgentLoopError::llm("OpenAI API error (403): forbidden");
810 assert!(err.is_auth_error());
811 }
812
813 #[test]
814 fn test_is_server_error_detects_500() {
815 let err = AgentLoopError::llm("Anthropic API error (500): internal server error");
816 assert!(err.is_server_error());
817 }
818
819 #[test]
820 fn test_is_server_error_detects_503() {
821 let err = AgentLoopError::llm("OpenAI API error (503): service unavailable");
822 assert!(err.is_server_error());
823 }
824
825 #[test]
826 fn test_user_facing_message_rate_limited() {
827 let err = AgentLoopError::llm("Anthropic API error (429): rate limit exceeded");
828 assert_eq!(
829 err.user_facing_message(),
830 "Rate limited by the AI provider. Please wait a moment."
831 );
832 }
833
834 #[test]
835 fn test_user_facing_message_auth_error() {
836 let err = AgentLoopError::llm("Anthropic API error (401): invalid api key");
837 assert_eq!(
838 err.user_facing_message(),
839 "There is a misconfiguration with the AI provider. Please contact support."
840 );
841 }
842
843 #[test]
844 fn test_user_facing_message_server_error() {
845 let err = AgentLoopError::llm("Anthropic API error (500): internal server error");
846 assert_eq!(
847 err.user_facing_message(),
848 "The AI provider is experiencing issues. Please try again shortly."
849 );
850 }
851
852 #[test]
853 fn test_user_facing_message_generic_fallback() {
854 let err = AgentLoopError::llm("Failed to send request: connection refused");
855 assert_eq!(
856 err.user_facing_message(),
857 "I encountered an error while processing your request. Please try again later."
858 );
859 }
860
861 #[test]
862 fn test_user_facing_message_model_not_available() {
863 let err = AgentLoopError::model_not_available("gpt-99");
864 assert!(err.user_facing_message().contains("gpt-99"));
865 assert!(err.user_facing_message().contains("not available"));
866 }
867
868 #[test]
869 fn test_user_facing_message_request_too_large() {
870 let err = AgentLoopError::request_too_large("context length exceeded");
871 assert!(err.user_facing_message().contains("too long"));
872 }
873
874 #[test]
875 fn test_user_facing_error_model_not_available_includes_model_id() {
876 let err = AgentLoopError::model_not_available("gpt-99");
877 let user_error = err.user_facing_error(UserFacingErrorContext::default());
878
879 assert_eq!(user_error.code, user_facing_error_codes::MODEL_UNAVAILABLE);
880 assert_eq!(
881 user_error.fields.get("model_id"),
882 Some(&serde_json::Value::String("gpt-99".to_string()))
883 );
884 }
885
886 #[test]
887 fn test_user_facing_error_rate_limited_includes_provider_context() {
888 let err = AgentLoopError::llm("Anthropic API error (429): rate limit exceeded");
889 let user_error = err.user_facing_error(
890 UserFacingErrorContext::default()
891 .with_provider("anthropic")
892 .with_model_id("claude-sonnet-4-5")
893 .with_retry_after(12),
894 );
895
896 assert_eq!(
897 user_error.code,
898 user_facing_error_codes::PROVIDER_RATE_LIMITED
899 );
900 assert_eq!(
901 user_error.fields.get("provider"),
902 Some(&serde_json::Value::String("anthropic".to_string()))
903 );
904 assert_eq!(
905 user_error.fields.get("model_id"),
906 Some(&serde_json::Value::String("claude-sonnet-4-5".to_string()))
907 );
908 assert_eq!(
909 user_error.fields.get("retry_after"),
910 Some(&serde_json::json!(12))
911 );
912 }
913
914 #[test]
915 fn test_llm_error_kind_from_provider_status() {
916 assert_eq!(
917 LlmErrorKind::from_provider_status(401, "invalid x-api-key"),
918 LlmErrorKind::Authentication
919 );
920 assert_eq!(
921 LlmErrorKind::from_provider_status(403, "forbidden"),
922 LlmErrorKind::Authentication
923 );
924 assert_eq!(
925 LlmErrorKind::from_provider_status(429, "rate limit exceeded"),
926 LlmErrorKind::RateLimited
927 );
928 assert_eq!(
930 LlmErrorKind::from_provider_status(
931 429,
932 "{\"error\":{\"type\":\"insufficient_quota\"}}"
933 ),
934 LlmErrorKind::QuotaExhausted
935 );
936 assert_eq!(
937 LlmErrorKind::from_provider_status(
938 429,
939 "{\"error\":{\"code\":\"credit_balance_exhausted\"}}"
940 ),
941 LlmErrorKind::QuotaExhausted
942 );
943 assert_eq!(
944 LlmErrorKind::from_provider_status(
945 429,
946 "{\"error\":{\"type\":\"usage_limit_reached\"}}"
947 ),
948 LlmErrorKind::QuotaExhausted
949 );
950 assert_eq!(
952 LlmErrorKind::from_provider_status(
953 400,
954 "Your credit balance is too low to access the Anthropic API."
955 ),
956 LlmErrorKind::QuotaExhausted
957 );
958 assert_eq!(
959 LlmErrorKind::from_provider_status(529, "overloaded"),
960 LlmErrorKind::Unavailable
961 );
962 assert_eq!(
963 LlmErrorKind::from_provider_status(503, "unavailable"),
964 LlmErrorKind::Unavailable
965 );
966 assert_eq!(
967 LlmErrorKind::from_provider_status(400, "bad request"),
968 LlmErrorKind::InvalidRequest
969 );
970 }
971
972 #[test]
973 fn test_llm_error_kind_from_error_text_bedrock() {
974 assert_eq!(
975 LlmErrorKind::from_error_text("ThrottlingException: Too many requests"),
976 LlmErrorKind::RateLimited
977 );
978 assert_eq!(
979 LlmErrorKind::from_error_text("AccessDeniedException: not authorized"),
980 LlmErrorKind::Authentication
981 );
982 assert_eq!(
983 LlmErrorKind::from_error_text("ServiceUnavailableException"),
984 LlmErrorKind::Unavailable
985 );
986 assert_eq!(
987 LlmErrorKind::from_error_text("usage_limit_reached; resets_at=1783767823"),
988 LlmErrorKind::QuotaExhausted
989 );
990 assert_eq!(
991 LlmErrorKind::from_error_text("something else entirely"),
992 LlmErrorKind::Other
993 );
994 }
995
996 #[test]
997 fn test_user_facing_error_prefers_semantic_kind() {
998 let err = AgentLoopError::llm_kind(
1001 LlmErrorKind::QuotaExhausted,
1002 "OpenAI API error (429): insufficient_quota",
1003 );
1004 let user_error =
1005 err.user_facing_error(UserFacingErrorContext::default().with_provider("openai"));
1006 assert_eq!(
1007 user_error.code,
1008 user_facing_error_codes::PROVIDER_QUOTA_EXHAUSTED
1009 );
1010 assert_eq!(
1011 user_error.fields.get("provider"),
1012 Some(&serde_json::Value::String("openai".to_string()))
1013 );
1014
1015 let err = AgentLoopError::llm_kind(LlmErrorKind::Authentication, "bad key");
1016 assert_eq!(
1017 err.user_facing_error(UserFacingErrorContext::default())
1018 .code,
1019 user_facing_error_codes::PROVIDER_MISCONFIGURED
1020 );
1021
1022 let err = AgentLoopError::llm_kind(LlmErrorKind::RateLimited, "slow down");
1023 let user_error =
1024 err.user_facing_error(UserFacingErrorContext::default().with_retry_after(5));
1025 assert_eq!(
1026 user_error.code,
1027 user_facing_error_codes::PROVIDER_RATE_LIMITED
1028 );
1029 assert_eq!(user_error.fields.get("retry_after"), Some(&json_val(&5)));
1030
1031 let err = AgentLoopError::llm_kind(LlmErrorKind::Unavailable, "overloaded");
1032 assert_eq!(
1033 err.user_facing_error(UserFacingErrorContext::default())
1034 .code,
1035 user_facing_error_codes::PROVIDER_UNAVAILABLE
1036 );
1037 }
1038
1039 #[test]
1040 fn test_semantic_kind_drives_predicates() {
1041 assert!(AgentLoopError::llm_kind(LlmErrorKind::RateLimited, "x").is_rate_limited());
1042 assert!(AgentLoopError::llm_kind(LlmErrorKind::Authentication, "x").is_auth_error());
1043 assert!(AgentLoopError::llm_kind(LlmErrorKind::Unavailable, "x").is_server_error());
1044 assert!(AgentLoopError::llm("error (429)").is_rate_limited());
1046 assert!(
1047 !AgentLoopError::llm_kind(LlmErrorKind::Authentication, "error (429)")
1048 .is_rate_limited()
1049 );
1050 }
1051
1052 #[test]
1053 fn test_store_result_ext_ok() {
1054 let result: std::result::Result<i32, String> = Ok(42);
1055 assert_eq!(result.store_err().unwrap(), 42);
1056 }
1057
1058 #[test]
1059 fn test_store_result_ext_err() {
1060 let result: std::result::Result<i32, String> = Err("db error".to_string());
1061 let err = result.store_err().unwrap_err();
1062 assert!(matches!(err, AgentLoopError::MessageStore(_)));
1063 assert!(err.to_string().contains("db error"));
1064 }
1065
1066 #[test]
1067 fn test_json_val() {
1068 let v = json_val(&vec![1, 2, 3]);
1069 assert_eq!(v, serde_json::json!([1, 2, 3]));
1070 }
1071
1072 #[test]
1073 fn test_from_json() {
1074 let v = serde_json::json!(["a", "b"]);
1075 let result: Vec<String> = from_json(v);
1076 assert_eq!(result, vec!["a", "b"]);
1077 }
1078
1079 #[test]
1080 fn test_from_json_default_on_mismatch() {
1081 let v = serde_json::json!("not a number");
1082 let result: i32 = from_json(v);
1083 assert_eq!(result, 0);
1084 }
1085}