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("Model not configured")]
160 ModelNotConfigured,
161
162 #[error("Tool execution error: {0}")]
164 ToolExecution(String),
165
166 #[error("Message store error: {0}")]
168 MessageStore(String),
169
170 #[error("Event emission error: {0}")]
172 EventEmission(String),
173
174 #[error("Configuration error: {0}")]
176 Configuration(String),
177
178 #[error("Max iterations ({0}) reached")]
180 MaxIterationsReached(usize),
181
182 #[error("Loop cancelled")]
184 Cancelled,
185
186 #[error("No messages to process")]
188 NoMessages,
189
190 #[error("Agent not found: {0}")]
192 AgentNotFound(AgentId),
193
194 #[error("Harness not found: {0}")]
196 HarnessNotFound(HarnessId),
197
198 #[error("Session not found: {0}")]
200 SessionNotFound(SessionId),
201
202 #[error("Internal error: {0}")]
204 Internal(#[from] anyhow::Error),
205
206 #[error(
208 "No driver registered for provider type '{0}'. Make sure the driver is registered at startup."
209 )]
210 DriverNotRegistered(String),
211}
212
213impl AgentLoopError {
214 pub fn with_provider(mut self, provider: &str) -> Self {
216 let prefix = format!("provider '{provider}': ");
217 match &mut self {
218 AgentLoopError::Llm(error) if !error.message.starts_with(&prefix) => {
219 error.message.insert_str(0, &prefix)
220 }
221 AgentLoopError::RequestTooLarge(message)
222 | AgentLoopError::ModelNotAvailable(message)
223 | AgentLoopError::Configuration(message)
224 if !message.starts_with(&prefix) =>
225 {
226 message.insert_str(0, &prefix)
227 }
228 _ => {}
229 }
230 self
231 }
232
233 pub fn llm(msg: impl Into<String>) -> Self {
236 AgentLoopError::Llm(LlmError {
237 kind: LlmErrorKind::Other,
238 message: msg.into(),
239 retry_attempts: 0,
240 retry_wait_ms: 0,
241 retry_handled: false,
242 })
243 }
244
245 pub fn llm_kind(kind: LlmErrorKind, msg: impl Into<String>) -> Self {
247 AgentLoopError::Llm(LlmError {
248 kind,
249 message: msg.into(),
250 retry_attempts: 0,
251 retry_wait_ms: 0,
252 retry_handled: false,
253 })
254 }
255
256 pub fn with_retry_metadata(mut self, metadata: &crate::llm_retry::RetryMetadata) -> Self {
259 if let AgentLoopError::Llm(error) = &mut self {
260 error.retry_attempts = metadata.attempts;
261 error.retry_wait_ms = metadata.total_retry_wait.as_millis() as u64;
262 error.retry_handled = true;
263 }
264 self
265 }
266
267 pub fn llm_retry_attempts(&self) -> u32 {
269 match self {
270 AgentLoopError::Llm(error) => error.retry_attempts,
271 _ => 0,
272 }
273 }
274
275 pub fn llm_retry_handled(&self) -> bool {
277 matches!(self, AgentLoopError::Llm(error) if error.retry_handled)
278 }
279
280 pub fn llm_error_kind(&self) -> Option<LlmErrorKind> {
282 match self {
283 AgentLoopError::Llm(err) => Some(err.kind),
284 _ => None,
285 }
286 }
287
288 pub fn tool(msg: impl Into<String>) -> Self {
290 AgentLoopError::ToolExecution(msg.into())
291 }
292
293 pub fn store(msg: impl Into<String>) -> Self {
295 AgentLoopError::MessageStore(msg.into())
296 }
297
298 pub fn event(msg: impl Into<String>) -> Self {
300 AgentLoopError::EventEmission(msg.into())
301 }
302
303 pub fn config(msg: impl Into<String>) -> Self {
305 AgentLoopError::Configuration(msg.into())
306 }
307
308 pub fn agent_not_found(agent_id: AgentId) -> Self {
310 AgentLoopError::AgentNotFound(agent_id)
311 }
312
313 pub fn harness_not_found(harness_id: HarnessId) -> Self {
315 AgentLoopError::HarnessNotFound(harness_id)
316 }
317
318 pub fn session_not_found(session_id: SessionId) -> Self {
320 AgentLoopError::SessionNotFound(session_id)
321 }
322
323 pub fn driver_not_registered(provider_type: impl Into<String>) -> Self {
325 AgentLoopError::DriverNotRegistered(provider_type.into())
326 }
327
328 pub fn request_too_large(msg: impl Into<String>) -> Self {
330 AgentLoopError::RequestTooLarge(msg.into())
331 }
332
333 pub fn model_not_available(model_id: impl Into<String>) -> Self {
335 AgentLoopError::ModelNotAvailable(model_id.into())
336 }
337
338 pub fn model_not_configured() -> Self {
340 AgentLoopError::ModelNotConfigured
341 }
342
343 pub fn is_request_too_large(&self) -> bool {
345 matches!(self, AgentLoopError::RequestTooLarge(_))
346 }
347
348 pub fn is_model_not_available(&self) -> bool {
350 matches!(self, AgentLoopError::ModelNotAvailable(_))
351 }
352
353 pub fn model_not_available_id(&self) -> Option<&str> {
355 match self {
356 AgentLoopError::ModelNotAvailable(id) => Some(id),
357 _ => None,
358 }
359 }
360
361 pub fn is_rate_limited(&self) -> bool {
364 match self {
365 AgentLoopError::Llm(err) => match err.kind {
366 LlmErrorKind::RateLimited => true,
367 LlmErrorKind::Other => {
368 let msg_lower = err.message.to_ascii_lowercase();
369 msg_lower.contains("(429)")
370 || msg_lower.contains("rate limit")
371 || msg_lower.contains("too many requests")
372 }
373 _ => false,
374 },
375 _ => false,
376 }
377 }
378
379 pub fn is_auth_error(&self) -> bool {
381 match self {
382 AgentLoopError::Llm(err) => match err.kind {
383 LlmErrorKind::Authentication => true,
384 LlmErrorKind::Other => {
385 err.message.contains("(401)") || err.message.contains("(403)")
386 }
387 _ => false,
388 },
389 _ => false,
390 }
391 }
392
393 pub fn is_server_error(&self) -> bool {
395 match self {
396 AgentLoopError::Llm(err) => match err.kind {
397 LlmErrorKind::Unavailable => true,
398 LlmErrorKind::Other => {
399 let msg = &err.message;
400 msg.contains("(500)")
401 || msg.contains("(502)")
402 || msg.contains("(503)")
403 || msg.contains("(504)")
404 || msg.contains("(529)")
405 }
406 _ => false,
407 },
408 _ => false,
409 }
410 }
411
412 pub fn is_transient_llm_error(&self) -> bool {
417 match self {
418 AgentLoopError::Llm(err) => match err.kind {
419 LlmErrorKind::RateLimited | LlmErrorKind::Unavailable => true,
420 LlmErrorKind::Authentication
421 | LlmErrorKind::QuotaExhausted
422 | LlmErrorKind::InvalidRequest => false,
423 LlmErrorKind::Other => crate::llm_retry::is_transient_error_message(&err.message),
424 },
425 _ => false,
426 }
427 }
428
429 pub fn is_non_retryable(&self) -> bool {
440 match self {
441 AgentLoopError::AgentNotFound(_)
443 | AgentLoopError::HarnessNotFound(_)
444 | AgentLoopError::SessionNotFound(_)
445 | AgentLoopError::NoMessages
446 | AgentLoopError::ModelNotConfigured => true,
447
448 AgentLoopError::Configuration(_) | AgentLoopError::DriverNotRegistered(_) => true,
450
451 AgentLoopError::MessageStore(msg) => msg.to_ascii_lowercase().contains("not found"),
453
454 _ => false,
456 }
457 }
458
459 pub fn user_facing_message(&self) -> String {
461 self.user_facing_error(UserFacingErrorContext::default())
462 .fallback_message()
463 }
464
465 pub fn user_facing_error(&self, context: UserFacingErrorContext) -> UserFacingError {
467 match self {
468 AgentLoopError::ModelNotConfigured => {
469 UserFacingError::new(user_facing_error_codes::MODEL_NOT_CONFIGURED)
470 }
471 AgentLoopError::ModelNotAvailable(model_id) => {
472 UserFacingError::new(user_facing_error_codes::MODEL_UNAVAILABLE)
473 .with_field("model_id", model_id)
474 .with_optional_field("provider", context.provider)
475 }
476 AgentLoopError::RequestTooLarge(_) => {
477 UserFacingError::new(user_facing_error_codes::REQUEST_TOO_LARGE)
478 .with_optional_field("provider", context.provider)
479 .with_optional_field("model_id", context.model_id)
480 }
481 AgentLoopError::MaxIterationsReached(max_iterations) => {
482 UserFacingError::new(user_facing_error_codes::MAX_ITERATIONS)
483 .with_field("max_iterations", max_iterations)
484 }
485 AgentLoopError::Llm(err) => {
486 let code = match err.kind {
490 LlmErrorKind::Authentication => {
491 Some(user_facing_error_codes::PROVIDER_MISCONFIGURED)
492 }
493 LlmErrorKind::QuotaExhausted => {
494 Some(user_facing_error_codes::PROVIDER_QUOTA_EXHAUSTED)
495 }
496 LlmErrorKind::RateLimited => {
497 Some(user_facing_error_codes::PROVIDER_RATE_LIMITED)
498 }
499 LlmErrorKind::Unavailable => {
500 Some(user_facing_error_codes::PROVIDER_UNAVAILABLE)
501 }
502 LlmErrorKind::InvalidRequest | LlmErrorKind::Other => None,
503 };
504 match code {
505 Some(code) => {
506 let error = UserFacingError::new(code)
507 .with_optional_field("provider", context.provider)
508 .with_optional_field("model_id", context.model_id);
509 if code == user_facing_error_codes::PROVIDER_RATE_LIMITED {
510 error.with_optional_field("retry_after", context.retry_after)
511 } else {
512 error
513 }
514 }
515 None => classify_runtime_error_message(&err.message, &context),
516 }
517 }
518 _ => UserFacingError::new(user_facing_error_codes::PROCESSING_ERROR)
519 .with_optional_field("provider", context.provider)
520 .with_optional_field("model_id", context.model_id),
521 }
522 }
523}
524
525pub trait StoreResultExt<T> {
541 fn store_err(self) -> Result<T>;
542}
543
544impl<T, E: std::fmt::Display> StoreResultExt<T> for std::result::Result<T, E> {
545 fn store_err(self) -> Result<T> {
546 self.map_err(|e| AgentLoopError::store(e.to_string()))
547 }
548}
549
550#[derive(Debug, Clone, Copy, PartialEq, Eq)]
570pub enum FileSystemErrorClass {
571 NotFound,
573 ReadOnly,
575 IsADirectory,
577 NotADirectory,
579 NotEmpty,
581 Other,
583}
584
585#[derive(Debug, Error)]
590pub enum FileSystemError {
591 #[error("{0}")]
592 NotFound(String),
593 #[error("{0}")]
594 ReadOnly(String),
595 #[error("{0}")]
596 IsADirectory(String),
597 #[error("{0}")]
598 NotADirectory(String),
599 #[error("{0}")]
600 NotEmpty(String),
601}
602
603impl FileSystemError {
604 fn class(&self) -> FileSystemErrorClass {
605 match self {
606 FileSystemError::NotFound(_) => FileSystemErrorClass::NotFound,
607 FileSystemError::ReadOnly(_) => FileSystemErrorClass::ReadOnly,
608 FileSystemError::IsADirectory(_) => FileSystemErrorClass::IsADirectory,
609 FileSystemError::NotADirectory(_) => FileSystemErrorClass::NotADirectory,
610 FileSystemError::NotEmpty(_) => FileSystemErrorClass::NotEmpty,
611 }
612 }
613}
614
615pub fn classify_fs_error<E>(err: &E) -> FileSystemErrorClass
625where
626 E: std::error::Error + 'static,
627{
628 let mut source: Option<&(dyn std::error::Error + 'static)> = Some(err);
633 while let Some(current) = source {
634 if let Some(typed) = current.downcast_ref::<FileSystemError>() {
635 return typed.class();
636 }
637 source = current.source();
638 }
639
640 let msg = err.to_string();
641 if msg.contains("readonly") {
645 FileSystemErrorClass::ReadOnly
646 } else if msg.contains("is a directory") {
647 FileSystemErrorClass::IsADirectory
648 } else if msg.contains("not a directory") {
649 FileSystemErrorClass::NotADirectory
650 } else if msg.contains("not empty") || msg.contains("recursive") {
651 FileSystemErrorClass::NotEmpty
652 } else if msg.contains("not found") {
653 FileSystemErrorClass::NotFound
654 } else {
655 FileSystemErrorClass::Other
656 }
657}
658
659pub fn json_val<T: Serialize>(value: &T) -> serde_json::Value {
670 serde_json::to_value(value).unwrap_or_default()
671}
672
673pub fn from_json<T: DeserializeOwned + Default>(value: serde_json::Value) -> T {
680 serde_json::from_value(value).unwrap_or_default()
681}
682
683#[cfg(test)]
684mod tests {
685 use super::*;
686
687 #[test]
691 fn classify_fs_error_prefers_typed_variant() {
692 let err = FileSystemError::ReadOnly("x".into());
693 assert_eq!(classify_fs_error(&err), FileSystemErrorClass::ReadOnly);
694 let err = FileSystemError::IsADirectory("x".into());
695 assert_eq!(classify_fs_error(&err), FileSystemErrorClass::IsADirectory);
696 }
697
698 #[test]
699 fn classify_fs_error_substring_fallback_matches_real_producers() {
700 let cases = [
703 (
704 "Cannot modify readonly file: /a",
705 FileSystemErrorClass::ReadOnly,
706 ),
707 (
708 "Cannot delete readonly file: /a",
709 FileSystemErrorClass::ReadOnly,
710 ),
711 (
712 "write target is a directory: /a",
713 FileSystemErrorClass::IsADirectory,
714 ),
715 (
716 "Path is not a directory: /a",
717 FileSystemErrorClass::NotADirectory,
718 ),
719 (
720 "workspace root is not a directory: /a",
721 FileSystemErrorClass::NotADirectory,
722 ),
723 ("Directory not found: /a", FileSystemErrorClass::NotFound),
724 (
725 "Directory is not empty. Use recursive=true to delete",
726 FileSystemErrorClass::NotEmpty,
727 ),
728 (
729 "Cannot delete root directory without recursive flag",
730 FileSystemErrorClass::NotEmpty,
731 ),
732 (
733 "recursive delete failed for /a: io",
734 FileSystemErrorClass::NotEmpty,
735 ),
736 ("disk full", FileSystemErrorClass::Other),
737 ];
738 for (msg, expected) in cases {
739 let err = AgentLoopError::store(msg);
740 assert_eq!(classify_fs_error(&err), expected, "msg: {msg}");
741 }
742 }
743
744 #[test]
747 fn classify_fs_error_classifies_typed_directly() {
748 let err = FileSystemError::NotEmpty("anything at all".into());
749 assert_eq!(classify_fs_error(&err), FileSystemErrorClass::NotEmpty);
750 }
751
752 #[test]
755 fn classify_fs_error_does_not_match_hyphenated_read_only() {
756 let err = AgentLoopError::store("file is read-only: /a");
757 assert_eq!(classify_fs_error(&err), FileSystemErrorClass::Other);
758 }
759
760 #[test]
761 fn test_is_request_too_large_returns_true_for_typed_error() {
762 let err = AgentLoopError::request_too_large("context length exceeded");
763 assert!(err.is_request_too_large());
764 }
765
766 #[test]
767 fn test_is_request_too_large_returns_false_for_llm_error() {
768 let err = AgentLoopError::llm("OpenAI API error (500): Internal server error");
769 assert!(!err.is_request_too_large());
770 }
771
772 #[test]
773 fn test_is_request_too_large_returns_false_for_other_errors() {
774 let err = AgentLoopError::ToolExecution("some error".to_string());
775 assert!(!err.is_request_too_large());
776
777 let err = AgentLoopError::Cancelled;
778 assert!(!err.is_request_too_large());
779 }
780
781 #[test]
782 fn test_request_too_large_error_preserves_message() {
783 let original_msg = "OpenAI API error (429): Request too large for gpt-4";
784 let err = AgentLoopError::request_too_large(original_msg);
785 assert_eq!(
786 err.to_string(),
787 format!("Request too large: {}", original_msg)
788 );
789 }
790
791 #[test]
792 fn test_is_model_not_available_returns_true_for_typed_error() {
793 let err = AgentLoopError::model_not_available("claude-sonnet-4-6-20260217");
794 assert!(err.is_model_not_available());
795 assert_eq!(
796 err.model_not_available_id(),
797 Some("claude-sonnet-4-6-20260217")
798 );
799 }
800
801 #[test]
802 fn test_is_model_not_available_returns_false_for_llm_error() {
803 let err = AgentLoopError::llm("some error");
804 assert!(!err.is_model_not_available());
805 assert_eq!(err.model_not_available_id(), None);
806 }
807
808 #[test]
809 fn test_model_not_available_error_display() {
810 let err = AgentLoopError::model_not_available("gpt-99");
811 assert_eq!(err.to_string(), "Model not available: gpt-99");
812 }
813
814 #[test]
815 fn test_is_rate_limited_detects_429() {
816 let err = AgentLoopError::llm("Anthropic API error (429): rate limit exceeded");
817 assert!(err.is_rate_limited());
818 }
819
820 #[test]
821 fn test_is_rate_limited_detects_rate_limit_keyword() {
822 let err =
823 AgentLoopError::llm("Rate limit exceeded (after 2 retries, last error: too many)");
824 assert!(err.is_rate_limited());
825 }
826
827 #[test]
828 fn test_is_rate_limited_false_for_server_error() {
829 let err = AgentLoopError::llm("Anthropic API error (500): internal server error");
830 assert!(!err.is_rate_limited());
831 }
832
833 #[test]
834 fn test_is_auth_error_detects_401() {
835 let err = AgentLoopError::llm("Anthropic API error (401): invalid api key");
836 assert!(err.is_auth_error());
837 }
838
839 #[test]
840 fn test_is_auth_error_detects_403() {
841 let err = AgentLoopError::llm("OpenAI API error (403): forbidden");
842 assert!(err.is_auth_error());
843 }
844
845 #[test]
846 fn test_is_server_error_detects_500() {
847 let err = AgentLoopError::llm("Anthropic API error (500): internal server error");
848 assert!(err.is_server_error());
849 }
850
851 #[test]
852 fn test_is_server_error_detects_503() {
853 let err = AgentLoopError::llm("OpenAI API error (503): service unavailable");
854 assert!(err.is_server_error());
855 }
856
857 #[test]
858 fn test_user_facing_message_rate_limited() {
859 let err = AgentLoopError::llm("Anthropic API error (429): rate limit exceeded");
860 assert_eq!(
861 err.user_facing_message(),
862 "Rate limited by the AI provider. Please wait a moment."
863 );
864 }
865
866 #[test]
867 fn test_user_facing_message_auth_error() {
868 let err = AgentLoopError::llm("Anthropic API error (401): invalid api key");
869 assert_eq!(
870 err.user_facing_message(),
871 "There is a misconfiguration with the AI provider. Please contact support."
872 );
873 }
874
875 #[test]
876 fn test_user_facing_message_server_error() {
877 let err = AgentLoopError::llm("Anthropic API error (500): internal server error");
878 assert_eq!(
879 err.user_facing_message(),
880 "The AI provider is experiencing issues. Please try again shortly."
881 );
882 }
883
884 #[test]
885 fn test_user_facing_message_generic_fallback() {
886 let err = AgentLoopError::llm("Failed to send request: connection refused");
887 assert_eq!(
888 err.user_facing_message(),
889 "I encountered an error while processing your request. Please try again later."
890 );
891 }
892
893 #[test]
894 fn test_user_facing_message_model_not_available() {
895 let err = AgentLoopError::model_not_available("gpt-99");
896 assert!(err.user_facing_message().contains("gpt-99"));
897 assert!(err.user_facing_message().contains("not available"));
898 }
899
900 #[test]
901 fn model_not_configured_is_typed_terminal_and_actionable() {
902 let err = AgentLoopError::model_not_configured();
903
904 assert!(err.is_non_retryable());
905 assert_eq!(
906 err.user_facing_error(UserFacingErrorContext::default())
907 .code,
908 user_facing_error_codes::MODEL_NOT_CONFIGURED
909 );
910 assert!(err.user_facing_message().contains("Choose a model"));
911 }
912
913 #[test]
914 fn test_user_facing_message_request_too_large() {
915 let err = AgentLoopError::request_too_large("context length exceeded");
916 assert!(err.user_facing_message().contains("too long"));
917 }
918
919 #[test]
920 fn test_user_facing_error_model_not_available_includes_model_id() {
921 let err = AgentLoopError::model_not_available("gpt-99");
922 let user_error = err.user_facing_error(UserFacingErrorContext::default());
923
924 assert_eq!(user_error.code, user_facing_error_codes::MODEL_UNAVAILABLE);
925 assert_eq!(
926 user_error.fields.get("model_id"),
927 Some(&serde_json::Value::String("gpt-99".to_string()))
928 );
929 }
930
931 #[test]
932 fn test_user_facing_error_rate_limited_includes_provider_context() {
933 let err = AgentLoopError::llm("Anthropic API error (429): rate limit exceeded");
934 let user_error = err.user_facing_error(
935 UserFacingErrorContext::default()
936 .with_provider("anthropic")
937 .with_model_id("claude-sonnet-4-5")
938 .with_retry_after(12),
939 );
940
941 assert_eq!(
942 user_error.code,
943 user_facing_error_codes::PROVIDER_RATE_LIMITED
944 );
945 assert_eq!(
946 user_error.fields.get("provider"),
947 Some(&serde_json::Value::String("anthropic".to_string()))
948 );
949 assert_eq!(
950 user_error.fields.get("model_id"),
951 Some(&serde_json::Value::String("claude-sonnet-4-5".to_string()))
952 );
953 assert_eq!(
954 user_error.fields.get("retry_after"),
955 Some(&serde_json::json!(12))
956 );
957 }
958
959 #[test]
960 fn test_llm_error_kind_from_provider_status() {
961 assert_eq!(
962 LlmErrorKind::from_provider_status(401, "invalid x-api-key"),
963 LlmErrorKind::Authentication
964 );
965 assert_eq!(
966 LlmErrorKind::from_provider_status(403, "forbidden"),
967 LlmErrorKind::Authentication
968 );
969 assert_eq!(
970 LlmErrorKind::from_provider_status(429, "rate limit exceeded"),
971 LlmErrorKind::RateLimited
972 );
973 assert_eq!(
975 LlmErrorKind::from_provider_status(
976 429,
977 "{\"error\":{\"type\":\"insufficient_quota\"}}"
978 ),
979 LlmErrorKind::QuotaExhausted
980 );
981 assert_eq!(
982 LlmErrorKind::from_provider_status(
983 429,
984 "{\"error\":{\"code\":\"credit_balance_exhausted\"}}"
985 ),
986 LlmErrorKind::QuotaExhausted
987 );
988 assert_eq!(
989 LlmErrorKind::from_provider_status(
990 429,
991 "{\"error\":{\"type\":\"usage_limit_reached\"}}"
992 ),
993 LlmErrorKind::QuotaExhausted
994 );
995 assert_eq!(
997 LlmErrorKind::from_provider_status(
998 400,
999 "Your credit balance is too low to access the Anthropic API."
1000 ),
1001 LlmErrorKind::QuotaExhausted
1002 );
1003 assert_eq!(
1004 LlmErrorKind::from_provider_status(529, "overloaded"),
1005 LlmErrorKind::Unavailable
1006 );
1007 assert_eq!(
1008 LlmErrorKind::from_provider_status(503, "unavailable"),
1009 LlmErrorKind::Unavailable
1010 );
1011 assert_eq!(
1012 LlmErrorKind::from_provider_status(400, "bad request"),
1013 LlmErrorKind::InvalidRequest
1014 );
1015 }
1016
1017 #[test]
1018 fn test_llm_error_kind_from_error_text_bedrock() {
1019 assert_eq!(
1020 LlmErrorKind::from_error_text("ThrottlingException: Too many requests"),
1021 LlmErrorKind::RateLimited
1022 );
1023 assert_eq!(
1024 LlmErrorKind::from_error_text("AccessDeniedException: not authorized"),
1025 LlmErrorKind::Authentication
1026 );
1027 assert_eq!(
1028 LlmErrorKind::from_error_text("ServiceUnavailableException"),
1029 LlmErrorKind::Unavailable
1030 );
1031 assert_eq!(
1032 LlmErrorKind::from_error_text("usage_limit_reached; resets_at=1783767823"),
1033 LlmErrorKind::QuotaExhausted
1034 );
1035 assert_eq!(
1036 LlmErrorKind::from_error_text("something else entirely"),
1037 LlmErrorKind::Other
1038 );
1039 }
1040
1041 #[test]
1042 fn test_user_facing_error_prefers_semantic_kind() {
1043 let err = AgentLoopError::llm_kind(
1046 LlmErrorKind::QuotaExhausted,
1047 "OpenAI API error (429): insufficient_quota",
1048 );
1049 let user_error =
1050 err.user_facing_error(UserFacingErrorContext::default().with_provider("openai"));
1051 assert_eq!(
1052 user_error.code,
1053 user_facing_error_codes::PROVIDER_QUOTA_EXHAUSTED
1054 );
1055 assert_eq!(
1056 user_error.fields.get("provider"),
1057 Some(&serde_json::Value::String("openai".to_string()))
1058 );
1059
1060 let err = AgentLoopError::llm_kind(LlmErrorKind::Authentication, "bad key");
1061 assert_eq!(
1062 err.user_facing_error(UserFacingErrorContext::default())
1063 .code,
1064 user_facing_error_codes::PROVIDER_MISCONFIGURED
1065 );
1066
1067 let err = AgentLoopError::llm_kind(LlmErrorKind::RateLimited, "slow down");
1068 let user_error =
1069 err.user_facing_error(UserFacingErrorContext::default().with_retry_after(5));
1070 assert_eq!(
1071 user_error.code,
1072 user_facing_error_codes::PROVIDER_RATE_LIMITED
1073 );
1074 assert_eq!(user_error.fields.get("retry_after"), Some(&json_val(&5)));
1075
1076 let err = AgentLoopError::llm_kind(LlmErrorKind::Unavailable, "overloaded");
1077 assert_eq!(
1078 err.user_facing_error(UserFacingErrorContext::default())
1079 .code,
1080 user_facing_error_codes::PROVIDER_UNAVAILABLE
1081 );
1082 }
1083
1084 #[test]
1085 fn test_semantic_kind_drives_predicates() {
1086 assert!(AgentLoopError::llm_kind(LlmErrorKind::RateLimited, "x").is_rate_limited());
1087 assert!(AgentLoopError::llm_kind(LlmErrorKind::Authentication, "x").is_auth_error());
1088 assert!(AgentLoopError::llm_kind(LlmErrorKind::Unavailable, "x").is_server_error());
1089 assert!(AgentLoopError::llm("error (429)").is_rate_limited());
1091 assert!(
1092 !AgentLoopError::llm_kind(LlmErrorKind::Authentication, "error (429)")
1093 .is_rate_limited()
1094 );
1095 }
1096
1097 #[test]
1098 fn test_store_result_ext_ok() {
1099 let result: std::result::Result<i32, String> = Ok(42);
1100 assert_eq!(result.store_err().unwrap(), 42);
1101 }
1102
1103 #[test]
1104 fn test_store_result_ext_err() {
1105 let result: std::result::Result<i32, String> = Err("db error".to_string());
1106 let err = result.store_err().unwrap_err();
1107 assert!(matches!(err, AgentLoopError::MessageStore(_)));
1108 assert!(err.to_string().contains("db error"));
1109 }
1110
1111 #[test]
1112 fn test_json_val() {
1113 let v = json_val(&vec![1, 2, 3]);
1114 assert_eq!(v, serde_json::json!([1, 2, 3]));
1115 }
1116
1117 #[test]
1118 fn test_from_json() {
1119 let v = serde_json::json!(["a", "b"]);
1120 let result: Vec<String> = from_json(v);
1121 assert_eq!(result, vec!["a", "b"]);
1122 }
1123
1124 #[test]
1125 fn test_from_json_default_on_mismatch() {
1126 let v = serde_json::json!("not a number");
1127 let result: i32 = from_json(v);
1128 assert_eq!(result, 0);
1129 }
1130}