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