1use crate::typed_id::{AgentId, HarnessId, SessionId};
7use crate::user_facing_error::{
8 AttestationRequirement, UserFacingError, UserFacingErrorContext,
9 classify_runtime_error_message, codes as user_facing_error_codes,
10 is_attestation_required_message, is_provider_quota_message, is_usage_limit_message,
11 parse_attestation_requirement,
12};
13use serde::{Deserialize, Serialize, de::DeserializeOwned};
14use thiserror::Error;
15
16pub type Result<T> = std::result::Result<T, AgentLoopError>;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum LlmErrorKind {
27 Authentication,
29 QuotaExhausted,
32 RateLimited,
34 Unavailable,
36 AttestationRequired,
42 InvalidRequest,
44 Other,
46}
47
48impl LlmErrorKind {
49 pub fn from_provider_code(code: &str) -> Option<Self> {
51 let code = code.trim().to_ascii_lowercase();
52 match code.as_str() {
53 "insufficient_quota"
54 | "billing_hard_limit_reached"
55 | "credit_balance_too_low"
56 | "credit_balance_exhausted" => Some(Self::QuotaExhausted),
57 "authentication_error" | "invalid_api_key" | "permission_denied" => {
58 Some(Self::Authentication)
59 }
60 "rate_limit_exceeded" | "rate_limit_error" | "overloaded_error" => {
61 Some(Self::RateLimited)
62 }
63 "server_error"
64 | "internal_error"
65 | "processing_error"
66 | "service_unavailable"
67 | "timeout" => Some(Self::Unavailable),
68 "invalid_request_error" | "model_not_found" => Some(Self::InvalidRequest),
69 _ => None,
70 }
71 }
72
73 pub fn from_provider_status(status: u16, body: &str) -> Self {
80 if is_provider_quota_message(body) || is_usage_limit_message(body) {
81 return LlmErrorKind::QuotaExhausted;
82 }
83 if is_attestation_required_message(body) {
87 return LlmErrorKind::AttestationRequired;
88 }
89 match status {
90 401 | 403 => LlmErrorKind::Authentication,
91 429 => LlmErrorKind::RateLimited,
92 408 | 409 => LlmErrorKind::Unavailable,
93 501 => LlmErrorKind::Other,
94 500..=599 => LlmErrorKind::Unavailable,
95 400..=499 => LlmErrorKind::InvalidRequest,
96 _ => LlmErrorKind::Other,
97 }
98 }
99
100 pub fn from_error_text(text: &str) -> Self {
103 if is_provider_quota_message(text) || is_usage_limit_message(text) {
104 return LlmErrorKind::QuotaExhausted;
105 }
106 let lower = text.to_ascii_lowercase();
107 if lower.contains("throttlingexception")
108 || lower.contains("toomanyrequestsexception")
109 || lower.contains("rate limit")
110 || lower.contains("too many requests")
111 {
112 return LlmErrorKind::RateLimited;
113 }
114 if lower.contains("accessdeniedexception")
115 || lower.contains("unrecognizedclientexception")
116 || lower.contains("expiredtokenexception")
117 || lower.contains("invalidsignatureexception")
118 || lower.contains("unauthorized")
119 {
120 return LlmErrorKind::Authentication;
121 }
122 if lower.contains("serviceunavailable")
123 || lower.contains("service unavailable")
124 || lower.contains("internalserverexception")
125 || lower.contains("modelnotreadyexception")
126 {
127 return LlmErrorKind::Unavailable;
128 }
129 LlmErrorKind::Other
130 }
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct LlmError {
136 pub kind: LlmErrorKind,
137 pub message: String,
138 #[serde(default)]
140 pub retry_attempts: u32,
141 #[serde(default)]
143 pub retry_wait_ms: u64,
144 #[serde(default)]
146 pub retry_handled: bool,
147}
148
149impl std::fmt::Display for LlmError {
150 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151 f.write_str(&self.message)
152 }
153}
154
155#[derive(Debug, Error)]
157pub enum AgentLoopError {
158 #[error("LLM error: {0}")]
160 Llm(LlmError),
161
162 #[error("Request too large: {0}")]
165 RequestTooLarge(String),
166
167 #[error("Model not available: {0}")]
170 ModelNotAvailable(String),
171
172 #[error("Model not configured")]
174 ModelNotConfigured,
175
176 #[error("Tool execution error: {0}")]
178 ToolExecution(String),
179
180 #[error("Message store error: {0}")]
182 MessageStore(String),
183
184 #[error("Event emission error: {0}")]
186 EventEmission(String),
187
188 #[error("Configuration error: {0}")]
190 Configuration(String),
191
192 #[error("Max iterations ({0}) reached")]
194 MaxIterationsReached(usize),
195
196 #[error("Loop cancelled")]
198 Cancelled,
199
200 #[error("No messages to process")]
202 NoMessages,
203
204 #[error("Agent not found: {0}")]
206 AgentNotFound(AgentId),
207
208 #[error("Harness not found: {0}")]
210 HarnessNotFound(HarnessId),
211
212 #[error("Session not found: {0}")]
214 SessionNotFound(SessionId),
215
216 #[error("Internal error: {0}")]
218 Internal(#[from] anyhow::Error),
219
220 #[error(
222 "No driver registered for provider type '{0}'. Make sure the driver is registered at startup."
223 )]
224 DriverNotRegistered(String),
225}
226
227impl AgentLoopError {
228 pub fn with_provider(mut self, provider: &str) -> Self {
230 let prefix = format!("provider '{provider}': ");
231 match &mut self {
232 AgentLoopError::Llm(error) if !error.message.starts_with(&prefix) => {
233 error.message.insert_str(0, &prefix)
234 }
235 AgentLoopError::RequestTooLarge(message) | AgentLoopError::Configuration(message)
236 if !message.starts_with(&prefix) =>
237 {
238 message.insert_str(0, &prefix)
239 }
240 _ => {}
242 }
243 self
244 }
245
246 pub fn llm(msg: impl Into<String>) -> Self {
249 AgentLoopError::Llm(LlmError {
250 kind: LlmErrorKind::Other,
251 message: msg.into(),
252 retry_attempts: 0,
253 retry_wait_ms: 0,
254 retry_handled: false,
255 })
256 }
257
258 pub fn llm_kind(kind: LlmErrorKind, msg: impl Into<String>) -> Self {
260 AgentLoopError::Llm(LlmError {
261 kind,
262 message: msg.into(),
263 retry_attempts: 0,
264 retry_wait_ms: 0,
265 retry_handled: false,
266 })
267 }
268
269 pub fn with_retry_metadata(mut self, metadata: &crate::llm_retry::RetryMetadata) -> Self {
272 if let AgentLoopError::Llm(error) = &mut self {
273 error.retry_attempts = metadata.attempts;
274 error.retry_wait_ms = metadata.total_retry_wait.as_millis() as u64;
275 error.retry_handled = true;
276 }
277 self
278 }
279
280 pub fn llm_retry_attempts(&self) -> u32 {
282 match self {
283 AgentLoopError::Llm(error) => error.retry_attempts,
284 _ => 0,
285 }
286 }
287
288 pub fn llm_retry_handled(&self) -> bool {
290 matches!(self, AgentLoopError::Llm(error) if error.retry_handled)
291 }
292
293 pub fn llm_error_kind(&self) -> Option<LlmErrorKind> {
295 match self {
296 AgentLoopError::Llm(err) => Some(err.kind),
297 _ => None,
298 }
299 }
300
301 pub fn tool(msg: impl Into<String>) -> Self {
303 AgentLoopError::ToolExecution(msg.into())
304 }
305
306 pub fn store(msg: impl Into<String>) -> Self {
308 AgentLoopError::MessageStore(msg.into())
309 }
310
311 pub fn event(msg: impl Into<String>) -> Self {
313 AgentLoopError::EventEmission(msg.into())
314 }
315
316 pub fn config(msg: impl Into<String>) -> Self {
318 AgentLoopError::Configuration(msg.into())
319 }
320
321 pub fn agent_not_found(agent_id: AgentId) -> Self {
323 AgentLoopError::AgentNotFound(agent_id)
324 }
325
326 pub fn harness_not_found(harness_id: HarnessId) -> Self {
328 AgentLoopError::HarnessNotFound(harness_id)
329 }
330
331 pub fn session_not_found(session_id: SessionId) -> Self {
333 AgentLoopError::SessionNotFound(session_id)
334 }
335
336 pub fn driver_not_registered(provider_type: impl Into<String>) -> Self {
338 AgentLoopError::DriverNotRegistered(provider_type.into())
339 }
340
341 pub fn request_too_large(msg: impl Into<String>) -> Self {
343 AgentLoopError::RequestTooLarge(msg.into())
344 }
345
346 pub fn model_not_available(model_id: impl Into<String>) -> Self {
348 AgentLoopError::ModelNotAvailable(model_id.into())
349 }
350
351 pub fn model_not_configured() -> Self {
353 AgentLoopError::ModelNotConfigured
354 }
355
356 pub fn is_request_too_large(&self) -> bool {
358 matches!(self, AgentLoopError::RequestTooLarge(_))
359 }
360
361 pub fn is_model_not_available(&self) -> bool {
363 matches!(self, AgentLoopError::ModelNotAvailable(_))
364 }
365
366 pub fn model_not_available_id(&self) -> Option<&str> {
368 match self {
369 AgentLoopError::ModelNotAvailable(id) => Some(id),
370 _ => None,
371 }
372 }
373
374 pub fn is_rate_limited(&self) -> bool {
377 match self {
378 AgentLoopError::Llm(err) => match err.kind {
379 LlmErrorKind::RateLimited => true,
380 LlmErrorKind::Other => {
381 let msg_lower = err.message.to_ascii_lowercase();
382 msg_lower.contains("(429)")
383 || msg_lower.contains("rate limit")
384 || msg_lower.contains("too many requests")
385 }
386 _ => false,
387 },
388 _ => false,
389 }
390 }
391
392 pub fn is_auth_error(&self) -> bool {
394 match self {
395 AgentLoopError::Llm(err) => match err.kind {
396 LlmErrorKind::Authentication => true,
397 LlmErrorKind::Other => {
398 err.message.contains("(401)") || err.message.contains("(403)")
399 }
400 _ => false,
401 },
402 _ => false,
403 }
404 }
405
406 pub fn is_server_error(&self) -> bool {
408 match self {
409 AgentLoopError::Llm(err) => match err.kind {
410 LlmErrorKind::Unavailable => true,
411 LlmErrorKind::Other => {
412 let msg = &err.message;
413 msg.contains("(500)")
414 || msg.contains("(502)")
415 || msg.contains("(503)")
416 || msg.contains("(504)")
417 || msg.contains("(529)")
418 }
419 _ => false,
420 },
421 _ => false,
422 }
423 }
424
425 pub fn is_transient_llm_error(&self) -> bool {
430 match self {
431 AgentLoopError::Llm(err) => match err.kind {
432 LlmErrorKind::RateLimited | LlmErrorKind::Unavailable => true,
433 LlmErrorKind::Authentication
434 | LlmErrorKind::QuotaExhausted
435 | LlmErrorKind::AttestationRequired
436 | LlmErrorKind::InvalidRequest => false,
437 LlmErrorKind::Other => crate::llm_retry::is_transient_error_message(&err.message),
438 },
439 _ => false,
440 }
441 }
442
443 pub fn is_non_retryable(&self) -> bool {
454 match self {
455 AgentLoopError::AgentNotFound(_)
457 | AgentLoopError::HarnessNotFound(_)
458 | AgentLoopError::SessionNotFound(_)
459 | AgentLoopError::NoMessages
460 | AgentLoopError::ModelNotConfigured => true,
461
462 AgentLoopError::Configuration(_) | AgentLoopError::DriverNotRegistered(_) => true,
464
465 AgentLoopError::MessageStore(msg) => msg.to_ascii_lowercase().contains("not found"),
467
468 _ => false,
470 }
471 }
472
473 pub fn user_facing_message(&self) -> String {
475 self.user_facing_error(UserFacingErrorContext::default())
476 .fallback_message()
477 }
478
479 pub fn user_facing_error(&self, context: UserFacingErrorContext) -> UserFacingError {
481 match self {
482 AgentLoopError::ModelNotConfigured => {
483 UserFacingError::new(user_facing_error_codes::MODEL_NOT_CONFIGURED)
484 }
485 AgentLoopError::ModelNotAvailable(model_id) => {
486 UserFacingError::new(user_facing_error_codes::MODEL_UNAVAILABLE)
487 .with_field("model_id", model_id)
488 .with_optional_field("provider", context.provider)
489 }
490 AgentLoopError::RequestTooLarge(_) => {
491 UserFacingError::new(user_facing_error_codes::REQUEST_TOO_LARGE)
492 .with_optional_field("provider", context.provider)
493 .with_optional_field("model_id", context.model_id)
494 }
495 AgentLoopError::MaxIterationsReached(max_iterations) => {
496 UserFacingError::new(user_facing_error_codes::MAX_ITERATIONS)
497 .with_field("max_iterations", max_iterations)
498 }
499 AgentLoopError::Llm(err) => {
500 let code = match err.kind {
504 LlmErrorKind::Authentication => {
505 Some(user_facing_error_codes::PROVIDER_MISCONFIGURED)
506 }
507 LlmErrorKind::QuotaExhausted => {
508 Some(user_facing_error_codes::PROVIDER_QUOTA_EXHAUSTED)
509 }
510 LlmErrorKind::RateLimited => {
511 Some(user_facing_error_codes::PROVIDER_RATE_LIMITED)
512 }
513 LlmErrorKind::Unavailable => {
514 Some(user_facing_error_codes::PROVIDER_UNAVAILABLE)
515 }
516 LlmErrorKind::AttestationRequired => {
517 Some(user_facing_error_codes::PROVIDER_ATTESTATION_REQUIRED)
518 }
519 LlmErrorKind::InvalidRequest | LlmErrorKind::Other => None,
520 };
521 match code {
522 Some(code) => {
523 let error = UserFacingError::new(code)
524 .with_optional_field("provider", context.provider)
525 .with_optional_field("model_id", context.model_id);
526 if code == user_facing_error_codes::PROVIDER_RATE_LIMITED {
527 error.with_optional_field("retry_after", context.retry_after)
528 } else if code == user_facing_error_codes::PROVIDER_ATTESTATION_REQUIRED {
529 parse_attestation_requirement(&err.message)
534 .unwrap_or_else(AttestationRequirement::fallback)
535 .apply_fields(error)
536 } else {
537 error
538 }
539 }
540 None => classify_runtime_error_message(&err.message, &context),
541 }
542 }
543 _ => UserFacingError::new(user_facing_error_codes::PROCESSING_ERROR)
544 .with_optional_field("provider", context.provider)
545 .with_optional_field("model_id", context.model_id),
546 }
547 }
548}
549
550pub trait StoreResultExt<T> {
566 fn store_err(self) -> Result<T>;
567}
568
569impl<T, E: std::fmt::Display> StoreResultExt<T> for std::result::Result<T, E> {
570 fn store_err(self) -> Result<T> {
571 self.map_err(|e| AgentLoopError::store(e.to_string()))
572 }
573}
574
575#[derive(Debug, Clone, Copy, PartialEq, Eq)]
595pub enum FileSystemErrorClass {
596 NotFound,
598 ReadOnly,
600 IsADirectory,
602 NotADirectory,
604 NotEmpty,
606 Other,
608}
609
610#[derive(Debug, Error)]
615pub enum FileSystemError {
616 #[error("{0}")]
617 NotFound(String),
618 #[error("{0}")]
619 ReadOnly(String),
620 #[error("{0}")]
621 IsADirectory(String),
622 #[error("{0}")]
623 NotADirectory(String),
624 #[error("{0}")]
625 NotEmpty(String),
626}
627
628impl FileSystemError {
629 fn class(&self) -> FileSystemErrorClass {
630 match self {
631 FileSystemError::NotFound(_) => FileSystemErrorClass::NotFound,
632 FileSystemError::ReadOnly(_) => FileSystemErrorClass::ReadOnly,
633 FileSystemError::IsADirectory(_) => FileSystemErrorClass::IsADirectory,
634 FileSystemError::NotADirectory(_) => FileSystemErrorClass::NotADirectory,
635 FileSystemError::NotEmpty(_) => FileSystemErrorClass::NotEmpty,
636 }
637 }
638}
639
640pub fn classify_fs_error<E>(err: &E) -> FileSystemErrorClass
650where
651 E: std::error::Error + 'static,
652{
653 let mut source: Option<&(dyn std::error::Error + 'static)> = Some(err);
658 while let Some(current) = source {
659 if let Some(typed) = current.downcast_ref::<FileSystemError>() {
660 return typed.class();
661 }
662 source = current.source();
663 }
664
665 let msg = err.to_string();
666 if msg.contains("readonly") {
670 FileSystemErrorClass::ReadOnly
671 } else if msg.contains("is a directory") {
672 FileSystemErrorClass::IsADirectory
673 } else if msg.contains("not a directory") {
674 FileSystemErrorClass::NotADirectory
675 } else if msg.contains("not empty") || msg.contains("recursive") {
676 FileSystemErrorClass::NotEmpty
677 } else if msg.contains("not found") {
678 FileSystemErrorClass::NotFound
679 } else {
680 FileSystemErrorClass::Other
681 }
682}
683
684pub fn json_val<T: Serialize>(value: &T) -> serde_json::Value {
695 serde_json::to_value(value).unwrap_or_default()
696}
697
698pub fn from_json<T: DeserializeOwned + Default>(value: serde_json::Value) -> T {
705 serde_json::from_value(value).unwrap_or_default()
706}
707
708#[cfg(test)]
709mod tests {
710 use super::*;
711 use serde_json::json;
712
713 #[test]
714 fn filesystem_typed_errors_win_over_conflicting_messages_and_wrappers() {
715 for (error, expected) in [
716 (
717 FileSystemError::NotFound("readonly".into()),
718 FileSystemErrorClass::NotFound,
719 ),
720 (
721 FileSystemError::ReadOnly("not found".into()),
722 FileSystemErrorClass::ReadOnly,
723 ),
724 (
725 FileSystemError::IsADirectory("not empty".into()),
726 FileSystemErrorClass::IsADirectory,
727 ),
728 (
729 FileSystemError::NotADirectory("is a directory".into()),
730 FileSystemErrorClass::NotADirectory,
731 ),
732 (
733 FileSystemError::NotEmpty("not found".into()),
734 FileSystemErrorClass::NotEmpty,
735 ),
736 ] {
737 assert_eq!(classify_fs_error(&error), expected);
738 let wrapped = AgentLoopError::Internal(
739 anyhow::Error::new(error).context("readonly outer failure"),
740 );
741 assert_eq!(classify_fs_error(&wrapped), expected);
742 }
743 }
744
745 #[test]
746 fn filesystem_legacy_messages_preserve_routing_and_case_boundaries() {
747 for (message, expected) in [
748 (
749 "Cannot modify readonly file: /a",
750 FileSystemErrorClass::ReadOnly,
751 ),
752 (
753 "Cannot delete readonly file: /a",
754 FileSystemErrorClass::ReadOnly,
755 ),
756 (
757 "write target is a directory: /a",
758 FileSystemErrorClass::IsADirectory,
759 ),
760 (
761 "Path is not a directory: /a",
762 FileSystemErrorClass::NotADirectory,
763 ),
764 (
765 "workspace root is not a directory: /a",
766 FileSystemErrorClass::NotADirectory,
767 ),
768 ("Directory not found: /a", FileSystemErrorClass::NotFound),
769 (
770 "Directory is not empty. Use recursive=true to delete",
771 FileSystemErrorClass::NotEmpty,
772 ),
773 (
774 "Cannot delete root directory without recursive flag",
775 FileSystemErrorClass::NotEmpty,
776 ),
777 (
778 "recursive delete failed for /a: io",
779 FileSystemErrorClass::NotEmpty,
780 ),
781 ("readonly file not found", FileSystemErrorClass::ReadOnly),
782 ("file is read-only: /a", FileSystemErrorClass::Other),
783 ("NOT FOUND", FileSystemErrorClass::Other),
784 ("disk full", FileSystemErrorClass::Other),
785 ] {
786 assert_eq!(
787 classify_fs_error(&AgentLoopError::store(message)),
788 expected,
789 "{message}"
790 );
791 }
792 }
793
794 #[test]
795 fn typed_request_and_model_errors_preserve_identity_and_safe_user_payload() {
796 let context = || {
797 UserFacingErrorContext::default()
798 .with_provider("provider")
799 .with_model_id("context-model")
800 .with_retry_after(9)
801 };
802 let request = AgentLoopError::request_too_large("private payload");
803 assert_eq!(request.to_string(), "Request too large: private payload");
804 assert!(request.is_request_too_large());
805 assert!(!request.is_model_not_available());
806 assert_eq!(request.model_not_available_id(), None);
807 assert_eq!(
808 serde_json::to_value(request.user_facing_error(context())).unwrap(),
809 json!({"code":"request_too_large","fields":{"provider":"provider","model_id":"context-model"}})
810 );
811 assert_eq!(
812 request.user_facing_message(),
813 "The conversation has become too long for the model to process. Please start a new session or reduce the context size."
814 );
815 let model = AgentLoopError::model_not_available("gpt-99")
816 .with_provider("custom")
817 .with_provider("custom");
818 assert!(!model.is_request_too_large());
819 assert!(model.is_model_not_available());
820 assert_eq!(model.model_not_available_id(), Some("gpt-99"));
821 assert_eq!(model.to_string(), "Model not available: gpt-99");
822 assert_eq!(
823 model.user_facing_message(),
824 "The model `gpt-99` is not available. It may have been removed, renamed, or your API key may not have access to it. Please select a different model."
825 );
826 assert_eq!(
827 serde_json::to_value(model.user_facing_error(context())).unwrap(),
828 json!({"code":"model_unavailable","fields":{"provider":"provider","model_id":"gpt-99"}})
829 );
830 for other in [
831 AgentLoopError::llm("Request too large: Model not available: gpt-99"),
832 AgentLoopError::tool("failed"),
833 AgentLoopError::Cancelled,
834 ] {
835 assert!(!other.is_request_too_large());
836 assert!(!other.is_model_not_available());
837 assert_eq!(other.model_not_available_id(), None);
838 }
839 }
840
841 #[test]
842 fn semantic_kinds_override_conflicting_text_for_predicates_and_payloads() {
843 for (kind, message, predicates, code) in [
844 (
845 LlmErrorKind::Authentication,
846 "(429) rate limit (503)",
847 (false, true, false, false),
848 "provider_misconfigured",
849 ),
850 (
851 LlmErrorKind::QuotaExhausted,
852 "(401) (429) rate limit (503)",
853 (false, false, false, false),
854 "provider_quota_exhausted",
855 ),
856 (
857 LlmErrorKind::RateLimited,
858 "(401) (503) insufficient_quota",
859 (true, false, false, true),
860 "provider_rate_limited",
861 ),
862 (
863 LlmErrorKind::Unavailable,
864 "(401) (429) insufficient_quota",
865 (false, false, true, true),
866 "provider_unavailable",
867 ),
868 (
869 LlmErrorKind::InvalidRequest,
870 "opaque private failure",
871 (false, false, false, false),
872 "processing_error",
873 ),
874 ] {
875 let error = AgentLoopError::llm_kind(kind, message);
876 assert_eq!(error.llm_error_kind(), Some(kind));
877 assert_eq!(
878 (
879 error.is_rate_limited(),
880 error.is_auth_error(),
881 error.is_server_error(),
882 error.is_transient_llm_error()
883 ),
884 predicates,
885 "{kind:?}"
886 );
887 let mut fields = json!({"provider":"provider","model_id":"model"});
888 if kind == LlmErrorKind::RateLimited {
889 fields["retry_after"] = json!(12);
890 }
891 assert_eq!(
892 serde_json::to_value(
893 error.user_facing_error(
894 UserFacingErrorContext::default()
895 .with_provider("provider")
896 .with_model_id("model")
897 .with_retry_after(12)
898 )
899 )
900 .unwrap(),
901 json!({"code":code,"fields":fields})
902 );
903 }
904 }
905
906 #[test]
907 fn legacy_predicates_and_user_copy_use_independent_literal_cases() {
908 for (message, expected, copy) in [
909 (
910 "Anthropic API error (429): rate limit exceeded",
911 (true, false, false),
912 "Rate limited by the AI provider. Please wait a moment.",
913 ),
914 (
915 "Rate limit exceeded (after 2 retries)",
916 (true, false, false),
917 "Rate limited by the AI provider. Please wait a moment.",
918 ),
919 (
920 "too many requests",
921 (true, false, false),
922 "Rate limited by the AI provider. Please wait a moment.",
923 ),
924 (
925 "Anthropic API error (401): invalid api key",
926 (false, true, false),
927 "There is a misconfiguration with the AI provider. Please contact support.",
928 ),
929 (
930 "OpenAI API error (403): forbidden",
931 (false, true, false),
932 "There is a misconfiguration with the AI provider. Please contact support.",
933 ),
934 (
935 "Anthropic API error (500): internal server error",
936 (false, false, true),
937 "The AI provider is experiencing issues. Please try again shortly.",
938 ),
939 (
940 "OpenAI API error (503): service unavailable",
941 (false, false, true),
942 "The AI provider is experiencing issues. Please try again shortly.",
943 ),
944 (
945 "Failed to send request: connection refused",
946 (false, false, false),
947 "I encountered an error while processing your request. Please try again later.",
948 ),
949 ] {
950 let error = AgentLoopError::llm(message);
951 assert_eq!(
952 (
953 error.is_rate_limited(),
954 error.is_auth_error(),
955 error.is_server_error()
956 ),
957 expected,
958 "{message}"
959 );
960 assert_eq!(error.user_facing_message(), copy, "{message}");
961 }
962 for status in [502, 504, 529] {
963 assert!(AgentLoopError::llm(format!("error ({status})")).is_server_error());
964 }
965 let non_llm = AgentLoopError::tool("(401) (429) (503) rate limit");
966 assert_eq!(
967 (
968 non_llm.is_rate_limited(),
969 non_llm.is_auth_error(),
970 non_llm.is_server_error(),
971 non_llm.is_transient_llm_error()
972 ),
973 (false, false, false, false)
974 );
975 }
976
977 #[test]
978 fn provider_status_classification_covers_boundaries_and_quota_precedence() {
979 for (status, expected) in [
980 (200, LlmErrorKind::Other),
981 (399, LlmErrorKind::Other),
982 (400, LlmErrorKind::InvalidRequest),
983 (401, LlmErrorKind::Authentication),
984 (403, LlmErrorKind::Authentication),
985 (404, LlmErrorKind::InvalidRequest),
986 (408, LlmErrorKind::Unavailable),
987 (409, LlmErrorKind::Unavailable),
988 (429, LlmErrorKind::RateLimited),
989 (499, LlmErrorKind::InvalidRequest),
990 (500, LlmErrorKind::Unavailable),
991 (501, LlmErrorKind::Other),
992 (502, LlmErrorKind::Unavailable),
993 (503, LlmErrorKind::Unavailable),
994 (529, LlmErrorKind::Unavailable),
995 (599, LlmErrorKind::Unavailable),
996 (600, LlmErrorKind::Other),
997 ] {
998 assert_eq!(
999 LlmErrorKind::from_provider_status(status, "opaque"),
1000 expected,
1001 "{status}"
1002 );
1003 }
1004 for message in [
1005 r#"{"error":{"type":"insufficient_quota"}}"#,
1006 r#"{"error":{"code":"credit_balance_exhausted"}}"#,
1007 r#"{"error":{"type":"usage_limit_reached"}}"#,
1008 "Your credit balance is too low to access the Anthropic API.",
1009 ] {
1010 for status in [400, 401, 429, 503] {
1011 assert_eq!(
1012 LlmErrorKind::from_provider_status(status, message),
1013 LlmErrorKind::QuotaExhausted,
1014 "{status}: {message}"
1015 );
1016 }
1017 }
1018 }
1019
1020 const ATTESTATION_BODY: &str = r#"{"error":{"message":"This model requires you to complete the following before use: 18+ age confirmation. Confirm at https://openrouter.ai/settings/preferences.","code":403,"metadata":{"missing_attestation_types":["age_18plus"],"routing_funnel":[{"step":"Initial Endpoints","endpoint_count":1}],"failed_routing_step":"Gate Endpoints with Attestations"}}}"#;
1022
1023 #[test]
1024 fn attestation_gate_is_classified_apart_from_other_403s() {
1025 assert_eq!(
1026 LlmErrorKind::from_provider_status(403, ATTESTATION_BODY),
1027 LlmErrorKind::AttestationRequired
1028 );
1029 assert_eq!(
1032 LlmErrorKind::from_provider_status(429, ATTESTATION_BODY),
1033 LlmErrorKind::AttestationRequired
1034 );
1035 for body in [
1036 r#"{"error":{"message":"Invalid credentials","code":403}}"#,
1037 r#"{"error":{"message":"Insufficient credits","code":403,"metadata":{"routing_funnel":[]}}}"#,
1038 "opaque",
1039 ] {
1040 assert_ne!(
1041 LlmErrorKind::from_provider_status(403, body),
1042 LlmErrorKind::AttestationRequired,
1043 "{body}"
1044 );
1045 }
1046 assert_eq!(
1048 LlmErrorKind::from_provider_status(
1049 403,
1050 r#"{"error":{"message":"insufficient_quota; requires you to complete the following before use"}}"#
1051 ),
1052 LlmErrorKind::QuotaExhausted
1053 );
1054 }
1055
1056 #[test]
1057 fn attestation_gate_reaches_the_reader_with_the_types_and_the_confirm_url() {
1058 let error = AgentLoopError::llm_kind(
1059 LlmErrorKind::AttestationRequired,
1060 format!("OpenAI Responses API error (403): {ATTESTATION_BODY}"),
1061 )
1062 .with_provider("openrouter");
1063 assert!(!error.is_auth_error());
1065 assert!(!error.is_transient_llm_error());
1066 assert_eq!(
1067 serde_json::to_value(
1068 error.user_facing_error(
1069 UserFacingErrorContext::default()
1070 .with_provider("openrouter")
1071 .with_model_id("meta/muse-spark-1.3-contributor")
1072 )
1073 )
1074 .unwrap(),
1075 json!({
1076 "code": "provider_attestation_required",
1077 "fields": {
1078 "provider": "openrouter",
1079 "model_id": "meta/muse-spark-1.3-contributor",
1080 "missing_types": ["age_18plus"],
1081 "confirm_url": "https://openrouter.ai/settings/preferences",
1082 }
1083 })
1084 );
1085 assert_eq!(
1086 error.user_facing_message(),
1087 "The AI provider account has not completed a confirmation this model requires (age_18plus). Complete it at https://openrouter.ai/settings/preferences, then try again."
1088 );
1089 }
1090
1091 #[test]
1092 fn untyped_attestation_bodies_still_route_off_the_403_misconfiguration_copy() {
1093 let error = AgentLoopError::llm(format!(
1096 "provider 'openrouter': OpenAI Responses API error (403): {ATTESTATION_BODY}"
1097 ));
1098 assert_eq!(
1099 error
1100 .user_facing_error(UserFacingErrorContext::default())
1101 .code,
1102 "provider_attestation_required"
1103 );
1104 }
1105
1106 #[test]
1107 fn attestation_parsing_covers_multiple_types_escaped_bodies_and_a_missing_url() {
1108 let requirement = |body: &str| {
1109 parse_attestation_requirement(body).unwrap_or_else(|| panic!("no gate in {body}"))
1110 };
1111
1112 let multiple = requirement(
1114 r#"{"error":{"message":"This model requires you to complete the following before use: 18+ age confirmation and identity verification. Confirm at https://openrouter.ai/settings/preferences.","metadata":{"missing_attestation_types":["age_18plus","identity_verified"]}}}"#,
1115 );
1116 assert_eq!(multiple.missing_types, ["age_18plus", "identity_verified"]);
1117 assert_eq!(
1118 multiple.confirm_url,
1119 "https://openrouter.ai/settings/preferences"
1120 );
1121
1122 let escaped = requirement(
1124 r#"{"detail":"{\"error\":{\"message\":\"This model requires you to complete the following before use: 18+ age confirmation. Confirm at https:\/\/openrouter.ai\/settings\/gates.\",\"metadata\":{\"missing_attestation_types\":[\"age_18plus\"]}}}"}"#,
1125 );
1126 assert_eq!(escaped.missing_types, ["age_18plus"]);
1127 assert_eq!(escaped.confirm_url, "https://openrouter.ai/settings/gates");
1128
1129 let no_url = requirement(
1132 r#"{"error":{"message":"This model requires you to complete the following before use: 18+ age confirmation.","metadata":{"missing_attestation_types":["age_18plus"]}}}"#,
1133 );
1134 assert_eq!(
1135 no_url.confirm_url,
1136 "https://openrouter.ai/settings/preferences"
1137 );
1138
1139 let sentence_only = requirement(
1141 "This model requires you to complete the following before use: 18+ age confirmation. Confirm at https://openrouter.ai/settings/preferences",
1142 );
1143 assert!(sentence_only.missing_types.is_empty());
1144 assert_eq!(
1145 sentence_only.confirm_url,
1146 "https://openrouter.ai/settings/preferences"
1147 );
1148 assert_eq!(
1151 AgentLoopError::llm_kind(
1152 LlmErrorKind::AttestationRequired,
1153 "This model requires you to complete the following before use: a confirmation."
1154 )
1155 .user_facing_message(),
1156 "The AI provider account has not completed a confirmation this model requires. Complete it at https://openrouter.ai/settings/preferences, then try again."
1157 );
1158
1159 assert_eq!(
1161 requirement(&format!(
1162 "POST https://openrouter.ai/api/v1/responses failed: {ATTESTATION_BODY}"
1163 ))
1164 .confirm_url,
1165 "https://openrouter.ai/settings/preferences"
1166 );
1167
1168 for body in [
1169 r#"{"error":{"message":"Invalid credentials"}}"#,
1170 r#"{"error":{"metadata":{"missing_attestation_types":[]}}}"#,
1171 "",
1172 ] {
1173 assert!(parse_attestation_requirement(body).is_none(), "{body}");
1174 }
1175 }
1176
1177 #[test]
1178 fn a_hostile_attestation_payload_cannot_choose_how_much_reaches_the_viewer() {
1179 let types = (0..40)
1180 .map(|index| format!(r#""gate_{index}""#))
1181 .collect::<Vec<_>>()
1182 .join(",");
1183 let long_type = "x".repeat(65);
1184 let long_url = format!("https://evil.example/{}", "a".repeat(400));
1185 let requirement = parse_attestation_requirement(&format!(
1186 r#"{{"error":{{"message":"This model requires you to complete the following before use: gates. Confirm at {long_url}","metadata":{{"missing_attestation_types":["{long_type}",{types}]}}}}}}"#
1187 ))
1188 .expect("gate recognized");
1189
1190 assert_eq!(requirement.missing_types.len(), 8);
1192 assert_eq!(requirement.missing_types[0], "gate_0");
1193 assert_eq!(
1195 requirement.confirm_url,
1196 "https://openrouter.ai/settings/preferences"
1197 );
1198
1199 for scheme in [
1201 "javascript:alert(1)",
1202 "data:text/html,<script>",
1203 "file:///etc/passwd",
1204 ] {
1205 assert_eq!(
1206 parse_attestation_requirement(&format!(
1207 "This model requires you to complete the following before use: a gate. Confirm at {scheme}"
1208 ))
1209 .expect("gate recognized")
1210 .confirm_url,
1211 "https://openrouter.ai/settings/preferences",
1212 "{scheme}"
1213 );
1214 }
1215 }
1216
1217 #[test]
1218 fn provider_text_classification_uses_independent_keywords_and_precedence() {
1219 for (message, expected) in [
1220 ("ThrottlingException", LlmErrorKind::RateLimited),
1221 ("TooManyRequestsException", LlmErrorKind::RateLimited),
1222 ("RATE LIMIT", LlmErrorKind::RateLimited),
1223 ("too many requests", LlmErrorKind::RateLimited),
1224 ("AccessDeniedException", LlmErrorKind::Authentication),
1225 ("UnrecognizedClientException", LlmErrorKind::Authentication),
1226 ("ExpiredTokenException", LlmErrorKind::Authentication),
1227 ("InvalidSignatureException", LlmErrorKind::Authentication),
1228 ("unauthorized", LlmErrorKind::Authentication),
1229 ("ServiceUnavailableException", LlmErrorKind::Unavailable),
1230 ("service unavailable", LlmErrorKind::Unavailable),
1231 ("InternalServerException", LlmErrorKind::Unavailable),
1232 ("ModelNotReadyException", LlmErrorKind::Unavailable),
1233 (
1234 "usage_limit_reached; resets_at=1783767823; throttlingexception",
1235 LlmErrorKind::QuotaExhausted,
1236 ),
1237 ("something else entirely", LlmErrorKind::Other),
1238 ] {
1239 assert_eq!(
1240 LlmErrorKind::from_error_text(message),
1241 expected,
1242 "{message}"
1243 );
1244 }
1245 }
1246
1247 #[test]
1248 fn provider_prefix_preserves_kind_and_retry_metadata_without_duplication() {
1249 let metadata = crate::llm_retry::RetryMetadata {
1250 attempts: 2,
1251 total_retry_wait: std::time::Duration::from_millis(1234),
1252 ..Default::default()
1253 };
1254 let error = AgentLoopError::llm_kind(LlmErrorKind::Unavailable, "network failure")
1255 .with_retry_metadata(&metadata)
1256 .with_provider("custom")
1257 .with_provider("custom");
1258 assert_eq!(error.llm_retry_attempts(), 2);
1259 assert!(error.llm_retry_handled());
1260 let AgentLoopError::Llm(error) = error else {
1261 panic!("lost LLM variant")
1262 };
1263 assert_eq!(
1264 serde_json::to_value(error).unwrap(),
1265 json!({"kind":"unavailable","message":"provider 'custom': network failure","retry_attempts":2,"retry_wait_ms":1234,"retry_handled":true})
1266 );
1267 let legacy: LlmError =
1268 serde_json::from_value(json!({"kind":"other","message":"legacy"})).unwrap();
1269 assert_eq!(
1270 serde_json::to_value(legacy).unwrap(),
1271 json!({"kind":"other","message":"legacy","retry_attempts":0,"retry_wait_ms":0,"retry_handled":false})
1272 );
1273 let non_llm = AgentLoopError::Cancelled
1274 .with_retry_metadata(&metadata)
1275 .with_provider("custom");
1276 assert!(matches!(non_llm, AgentLoopError::Cancelled));
1277 assert_eq!(non_llm.llm_retry_attempts(), 0);
1278 assert!(!non_llm.llm_retry_handled());
1279 }
1280
1281 #[test]
1282 fn missing_model_and_iteration_limits_have_complete_safe_payloads() {
1283 let missing = AgentLoopError::model_not_configured();
1284 assert!(missing.is_non_retryable());
1285 assert_eq!(
1286 missing.user_facing_message(),
1287 "No model is configured for this chat. Choose a model or configure a default model, then try again."
1288 );
1289 assert_eq!(
1290 serde_json::to_value(missing.user_facing_error(UserFacingErrorContext::default()))
1291 .unwrap(),
1292 json!({"code":"model_not_configured"})
1293 );
1294 assert_eq!(
1295 serde_json::to_value(
1296 AgentLoopError::MaxIterationsReached(7)
1297 .user_facing_error(UserFacingErrorContext::default())
1298 )
1299 .unwrap(),
1300 json!({"code":"max_iterations","fields":{"max_iterations":7}})
1301 );
1302 }
1303
1304 #[test]
1305 fn store_adapter_preserves_success_and_exact_error_variant_and_message() {
1306 let success: std::result::Result<Vec<String>, String> =
1307 Ok(vec!["first".into(), "second".into()]);
1308 assert_eq!(success.store_err().unwrap(), ["first", "second"]);
1309 let failure: std::result::Result<(), std::io::Error> =
1310 Err(std::io::Error::other("db unavailable"));
1311 let error = failure.store_err().unwrap_err();
1312 assert_eq!(error.to_string(), "Message store error: db unavailable");
1313 assert!(matches!(error,AgentLoopError::MessageStore(message) if message=="db unavailable"));
1314 }
1315
1316 #[test]
1317 fn json_helpers_preserve_structures_and_apply_documented_error_defaults() {
1318 assert_eq!(json_val(&vec![1, 2, 3]), json!([1, 2, 3]));
1319 assert_eq!(from_json::<Vec<String>>(json!(["a", "b"])), ["a", "b"]);
1320 assert_eq!(from_json::<i32>(json!("not a number")), 0);
1321 struct Fails;
1322 impl Serialize for Fails {
1323 fn serialize<S: serde::Serializer>(
1324 &self,
1325 _: S,
1326 ) -> std::result::Result<S::Ok, S::Error> {
1327 Err(serde::ser::Error::custom("synthetic serialization failure"))
1328 }
1329 }
1330 assert_eq!(json_val(&Fails), serde_json::Value::Null);
1331 }
1332}