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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum BillingPressureReason {
22 InFlightBudgetExhausted,
24 InsufficientCredits,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35#[non_exhaustive]
36pub enum LlmErrorKind {
37 Authentication,
39 QuotaExhausted,
42 BillingPressure {
46 reason: BillingPressureReason,
48 retry_after_secs: Option<u64>,
50 },
51 RateLimited,
53 Unavailable,
55 AttestationRequired,
61 InvalidRequest,
63 Other,
65}
66
67impl LlmErrorKind {
68 pub fn from_provider_code(code: &str) -> Option<Self> {
70 let code = code.trim().to_ascii_lowercase();
71 match code.as_str() {
72 "insufficient_quota"
73 | "billing_hard_limit_reached"
74 | "credit_balance_too_low"
75 | "credit_balance_exhausted" => Some(Self::QuotaExhausted),
76 "authentication_error" | "invalid_api_key" | "permission_denied" => {
77 Some(Self::Authentication)
78 }
79 "rate_limit_exceeded" | "rate_limit_error" | "overloaded_error" => {
80 Some(Self::RateLimited)
81 }
82 "server_error"
83 | "internal_error"
84 | "processing_error"
85 | "service_unavailable"
86 | "timeout" => Some(Self::Unavailable),
87 "invalid_request_error" | "model_not_found" => Some(Self::InvalidRequest),
88 _ => None,
89 }
90 }
91
92 pub fn from_provider_status(status: u16, body: &str) -> Self {
99 if is_provider_quota_message(body) || is_usage_limit_message(body) {
100 return LlmErrorKind::QuotaExhausted;
101 }
102 if is_attestation_required_message(body) {
106 return LlmErrorKind::AttestationRequired;
107 }
108 match status {
109 401 | 403 => LlmErrorKind::Authentication,
110 429 => LlmErrorKind::RateLimited,
111 408 | 409 => LlmErrorKind::Unavailable,
112 501 => LlmErrorKind::Other,
113 500..=599 => LlmErrorKind::Unavailable,
114 400..=499 => LlmErrorKind::InvalidRequest,
115 _ => LlmErrorKind::Other,
116 }
117 }
118
119 pub fn from_error_text(text: &str) -> Self {
122 if is_provider_quota_message(text) || is_usage_limit_message(text) {
123 return LlmErrorKind::QuotaExhausted;
124 }
125 let lower = text.to_ascii_lowercase();
126 if lower.contains("throttlingexception")
127 || lower.contains("toomanyrequestsexception")
128 || lower.contains("rate limit")
129 || lower.contains("too many requests")
130 {
131 return LlmErrorKind::RateLimited;
132 }
133 if lower.contains("accessdeniedexception")
134 || lower.contains("unrecognizedclientexception")
135 || lower.contains("expiredtokenexception")
136 || lower.contains("invalidsignatureexception")
137 || lower.contains("unauthorized")
138 {
139 return LlmErrorKind::Authentication;
140 }
141 if lower.contains("serviceunavailable")
142 || lower.contains("service unavailable")
143 || lower.contains("internalserverexception")
144 || lower.contains("modelnotreadyexception")
145 {
146 return LlmErrorKind::Unavailable;
147 }
148 LlmErrorKind::Other
149 }
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct LlmError {
155 pub kind: LlmErrorKind,
156 pub message: String,
157 #[serde(default)]
159 pub retry_attempts: u32,
160 #[serde(default)]
162 pub retry_wait_ms: u64,
163 #[serde(default)]
165 pub retry_handled: bool,
166}
167
168impl std::fmt::Display for LlmError {
169 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170 f.write_str(&self.message)
171 }
172}
173
174#[derive(Debug, Error)]
176pub enum AgentLoopError {
177 #[error("LLM error: {0}")]
179 Llm(LlmError),
180
181 #[error("Request too large: {0}")]
184 RequestTooLarge(String),
185
186 #[error("Model not available: {0}")]
189 ModelNotAvailable(String),
190
191 #[error("Model not configured")]
193 ModelNotConfigured,
194
195 #[error("Tool execution error: {0}")]
197 ToolExecution(String),
198
199 #[error("Message store error: {0}")]
201 MessageStore(String),
202
203 #[error("Event emission error: {0}")]
205 EventEmission(String),
206
207 #[error("Configuration error: {0}")]
209 Configuration(String),
210
211 #[error("Max iterations ({0}) reached")]
213 MaxIterationsReached(usize),
214
215 #[error("Loop cancelled")]
217 Cancelled,
218
219 #[error("No messages to process")]
221 NoMessages,
222
223 #[error("Agent not found: {0}")]
225 AgentNotFound(AgentId),
226
227 #[error("Harness not found: {0}")]
229 HarnessNotFound(HarnessId),
230
231 #[error("Session not found: {0}")]
233 SessionNotFound(SessionId),
234
235 #[error("Internal error: {0}")]
237 Internal(#[from] anyhow::Error),
238
239 #[error(
241 "No driver registered for provider type '{0}'. Make sure the driver is registered at startup."
242 )]
243 DriverNotRegistered(String),
244}
245
246impl AgentLoopError {
247 pub fn with_provider(mut self, provider: &str) -> Self {
249 let prefix = format!("provider '{provider}': ");
250 match &mut self {
251 AgentLoopError::Llm(error) if !error.message.starts_with(&prefix) => {
252 error.message.insert_str(0, &prefix)
253 }
254 AgentLoopError::RequestTooLarge(message) | AgentLoopError::Configuration(message)
255 if !message.starts_with(&prefix) =>
256 {
257 message.insert_str(0, &prefix)
258 }
259 _ => {}
261 }
262 self
263 }
264
265 pub fn llm(msg: impl Into<String>) -> Self {
268 AgentLoopError::Llm(LlmError {
269 kind: LlmErrorKind::Other,
270 message: msg.into(),
271 retry_attempts: 0,
272 retry_wait_ms: 0,
273 retry_handled: false,
274 })
275 }
276
277 pub fn llm_kind(kind: LlmErrorKind, msg: impl Into<String>) -> Self {
279 AgentLoopError::Llm(LlmError {
280 kind,
281 message: msg.into(),
282 retry_attempts: 0,
283 retry_wait_ms: 0,
284 retry_handled: false,
285 })
286 }
287
288 pub fn with_retry_metadata(mut self, metadata: &crate::llm_retry::RetryMetadata) -> Self {
291 if let AgentLoopError::Llm(error) = &mut self {
292 error.retry_attempts = metadata.attempts;
293 error.retry_wait_ms = metadata.total_retry_wait.as_millis() as u64;
294 error.retry_handled = true;
295 }
296 self
297 }
298
299 pub fn llm_retry_attempts(&self) -> u32 {
301 match self {
302 AgentLoopError::Llm(error) => error.retry_attempts,
303 _ => 0,
304 }
305 }
306
307 pub fn llm_retry_handled(&self) -> bool {
309 matches!(self, AgentLoopError::Llm(error) if error.retry_handled)
310 }
311
312 pub fn llm_error_kind(&self) -> Option<LlmErrorKind> {
314 match self {
315 AgentLoopError::Llm(err) => Some(err.kind),
316 _ => None,
317 }
318 }
319
320 pub fn tool(msg: impl Into<String>) -> Self {
322 AgentLoopError::ToolExecution(msg.into())
323 }
324
325 pub fn store(msg: impl Into<String>) -> Self {
327 AgentLoopError::MessageStore(msg.into())
328 }
329
330 pub fn event(msg: impl Into<String>) -> Self {
332 AgentLoopError::EventEmission(msg.into())
333 }
334
335 pub fn config(msg: impl Into<String>) -> Self {
337 AgentLoopError::Configuration(msg.into())
338 }
339
340 pub fn agent_not_found(agent_id: AgentId) -> Self {
342 AgentLoopError::AgentNotFound(agent_id)
343 }
344
345 pub fn harness_not_found(harness_id: HarnessId) -> Self {
347 AgentLoopError::HarnessNotFound(harness_id)
348 }
349
350 pub fn session_not_found(session_id: SessionId) -> Self {
352 AgentLoopError::SessionNotFound(session_id)
353 }
354
355 pub fn driver_not_registered(provider_type: impl Into<String>) -> Self {
357 AgentLoopError::DriverNotRegistered(provider_type.into())
358 }
359
360 pub fn request_too_large(msg: impl Into<String>) -> Self {
362 AgentLoopError::RequestTooLarge(msg.into())
363 }
364
365 pub fn model_not_available(model_id: impl Into<String>) -> Self {
367 AgentLoopError::ModelNotAvailable(model_id.into())
368 }
369
370 pub fn model_not_configured() -> Self {
372 AgentLoopError::ModelNotConfigured
373 }
374
375 pub fn is_request_too_large(&self) -> bool {
377 matches!(self, AgentLoopError::RequestTooLarge(_))
378 }
379
380 pub fn is_model_not_available(&self) -> bool {
382 matches!(self, AgentLoopError::ModelNotAvailable(_))
383 }
384
385 pub fn model_not_available_id(&self) -> Option<&str> {
387 match self {
388 AgentLoopError::ModelNotAvailable(id) => Some(id),
389 _ => None,
390 }
391 }
392
393 pub fn is_rate_limited(&self) -> bool {
396 match self {
397 AgentLoopError::Llm(err) => match err.kind {
398 LlmErrorKind::RateLimited => true,
399 LlmErrorKind::Other => {
400 let msg_lower = err.message.to_ascii_lowercase();
401 msg_lower.contains("(429)")
402 || msg_lower.contains("rate limit")
403 || msg_lower.contains("too many requests")
404 }
405 _ => false,
406 },
407 _ => false,
408 }
409 }
410
411 pub fn is_auth_error(&self) -> bool {
413 match self {
414 AgentLoopError::Llm(err) => match err.kind {
415 LlmErrorKind::Authentication => true,
416 LlmErrorKind::Other => {
417 err.message.contains("(401)") || err.message.contains("(403)")
418 }
419 _ => false,
420 },
421 _ => false,
422 }
423 }
424
425 pub fn is_server_error(&self) -> bool {
427 match self {
428 AgentLoopError::Llm(err) => match err.kind {
429 LlmErrorKind::Unavailable => true,
430 LlmErrorKind::Other => {
431 let msg = &err.message;
432 msg.contains("(500)")
433 || msg.contains("(502)")
434 || msg.contains("(503)")
435 || msg.contains("(504)")
436 || msg.contains("(529)")
437 }
438 _ => false,
439 },
440 _ => false,
441 }
442 }
443
444 pub fn is_transient_llm_error(&self) -> bool {
449 match self {
450 AgentLoopError::Llm(err) => match err.kind {
451 LlmErrorKind::RateLimited | LlmErrorKind::Unavailable => true,
452 LlmErrorKind::Authentication
453 | LlmErrorKind::QuotaExhausted
454 | LlmErrorKind::BillingPressure { .. }
455 | LlmErrorKind::AttestationRequired
456 | LlmErrorKind::InvalidRequest => false,
457 LlmErrorKind::Other => crate::llm_retry::is_transient_error_message(&err.message),
458 },
459 _ => false,
460 }
461 }
462
463 pub fn is_non_retryable(&self) -> bool {
474 match self {
475 AgentLoopError::AgentNotFound(_)
477 | AgentLoopError::HarnessNotFound(_)
478 | AgentLoopError::SessionNotFound(_)
479 | AgentLoopError::NoMessages
480 | AgentLoopError::ModelNotConfigured => true,
481
482 AgentLoopError::Configuration(_) | AgentLoopError::DriverNotRegistered(_) => true,
484
485 AgentLoopError::MessageStore(msg) => msg.to_ascii_lowercase().contains("not found"),
487
488 _ => false,
490 }
491 }
492
493 pub fn user_facing_message(&self) -> String {
495 self.user_facing_error(UserFacingErrorContext::default())
496 .fallback_message()
497 }
498
499 pub fn user_facing_error(&self, context: UserFacingErrorContext) -> UserFacingError {
501 match self {
502 AgentLoopError::ModelNotConfigured => {
503 UserFacingError::new(user_facing_error_codes::MODEL_NOT_CONFIGURED)
504 }
505 AgentLoopError::ModelNotAvailable(model_id) => {
506 UserFacingError::new(user_facing_error_codes::MODEL_UNAVAILABLE)
507 .with_field("model_id", model_id)
508 .with_optional_field("provider", context.provider)
509 }
510 AgentLoopError::RequestTooLarge(_) => {
511 UserFacingError::new(user_facing_error_codes::REQUEST_TOO_LARGE)
512 .with_optional_field("provider", context.provider)
513 .with_optional_field("model_id", context.model_id)
514 }
515 AgentLoopError::MaxIterationsReached(max_iterations) => {
516 UserFacingError::new(user_facing_error_codes::MAX_ITERATIONS)
517 .with_field("max_iterations", max_iterations)
518 }
519 AgentLoopError::Llm(err) => {
520 let code = match err.kind {
524 LlmErrorKind::Authentication => {
525 Some(user_facing_error_codes::PROVIDER_MISCONFIGURED)
526 }
527 LlmErrorKind::QuotaExhausted => {
528 Some(user_facing_error_codes::PROVIDER_QUOTA_EXHAUSTED)
529 }
530 LlmErrorKind::BillingPressure { reason, .. } => Some(match reason {
531 BillingPressureReason::InFlightBudgetExhausted => {
532 user_facing_error_codes::PROVIDER_RATE_LIMITED
533 }
534 BillingPressureReason::InsufficientCredits => {
535 user_facing_error_codes::PROVIDER_QUOTA_EXHAUSTED
536 }
537 }),
538 LlmErrorKind::RateLimited => {
539 Some(user_facing_error_codes::PROVIDER_RATE_LIMITED)
540 }
541 LlmErrorKind::Unavailable => {
542 Some(user_facing_error_codes::PROVIDER_UNAVAILABLE)
543 }
544 LlmErrorKind::AttestationRequired => {
545 Some(user_facing_error_codes::PROVIDER_ATTESTATION_REQUIRED)
546 }
547 LlmErrorKind::InvalidRequest | LlmErrorKind::Other => None,
548 };
549 match code {
550 Some(code) => {
551 let error = UserFacingError::new(code)
552 .with_optional_field("provider", context.provider)
553 .with_optional_field("model_id", context.model_id);
554 if code == user_facing_error_codes::PROVIDER_RATE_LIMITED {
555 let retry_after = match err.kind {
556 LlmErrorKind::BillingPressure {
557 retry_after_secs, ..
558 } => retry_after_secs,
559 _ => context.retry_after,
560 };
561 error.with_optional_field("retry_after", retry_after)
562 } else if code == user_facing_error_codes::PROVIDER_ATTESTATION_REQUIRED {
563 parse_attestation_requirement(&err.message)
567 .unwrap_or_else(AttestationRequirement::fallback)
568 .apply_fields(error)
569 } else {
570 error
571 }
572 }
573 None => classify_runtime_error_message(&err.message, &context),
574 }
575 }
576 _ => UserFacingError::new(user_facing_error_codes::PROCESSING_ERROR)
577 .with_optional_field("provider", context.provider)
578 .with_optional_field("model_id", context.model_id),
579 }
580 }
581}
582
583pub trait StoreResultExt<T> {
599 fn store_err(self) -> Result<T>;
600}
601
602impl<T, E: std::fmt::Display> StoreResultExt<T> for std::result::Result<T, E> {
603 fn store_err(self) -> Result<T> {
604 self.map_err(|e| AgentLoopError::store(e.to_string()))
605 }
606}
607
608#[derive(Debug, Clone, Copy, PartialEq, Eq)]
628pub enum FileSystemErrorClass {
629 NotFound,
631 ReadOnly,
633 IsADirectory,
635 NotADirectory,
637 NotEmpty,
639 Other,
641}
642
643#[derive(Debug, Error)]
648pub enum FileSystemError {
649 #[error("{0}")]
650 NotFound(String),
651 #[error("{0}")]
652 ReadOnly(String),
653 #[error("{0}")]
654 IsADirectory(String),
655 #[error("{0}")]
656 NotADirectory(String),
657 #[error("{0}")]
658 NotEmpty(String),
659}
660
661impl FileSystemError {
662 fn class(&self) -> FileSystemErrorClass {
663 match self {
664 FileSystemError::NotFound(_) => FileSystemErrorClass::NotFound,
665 FileSystemError::ReadOnly(_) => FileSystemErrorClass::ReadOnly,
666 FileSystemError::IsADirectory(_) => FileSystemErrorClass::IsADirectory,
667 FileSystemError::NotADirectory(_) => FileSystemErrorClass::NotADirectory,
668 FileSystemError::NotEmpty(_) => FileSystemErrorClass::NotEmpty,
669 }
670 }
671}
672
673pub fn classify_fs_error<E>(err: &E) -> FileSystemErrorClass
683where
684 E: std::error::Error + 'static,
685{
686 let mut source: Option<&(dyn std::error::Error + 'static)> = Some(err);
691 while let Some(current) = source {
692 if let Some(typed) = current.downcast_ref::<FileSystemError>() {
693 return typed.class();
694 }
695 source = current.source();
696 }
697
698 let msg = err.to_string();
699 if msg.contains("readonly") {
703 FileSystemErrorClass::ReadOnly
704 } else if msg.contains("is a directory") {
705 FileSystemErrorClass::IsADirectory
706 } else if msg.contains("not a directory") {
707 FileSystemErrorClass::NotADirectory
708 } else if msg.contains("not empty") || msg.contains("recursive") {
709 FileSystemErrorClass::NotEmpty
710 } else if msg.contains("not found") {
711 FileSystemErrorClass::NotFound
712 } else {
713 FileSystemErrorClass::Other
714 }
715}
716
717pub fn json_val<T: Serialize>(value: &T) -> serde_json::Value {
728 serde_json::to_value(value).unwrap_or_default()
729}
730
731pub fn from_json<T: DeserializeOwned + Default>(value: serde_json::Value) -> T {
738 serde_json::from_value(value).unwrap_or_default()
739}
740
741#[cfg(test)]
742mod tests {
743 use super::*;
744 use serde_json::json;
745
746 #[test]
747 fn filesystem_typed_errors_win_over_conflicting_messages_and_wrappers() {
748 for (error, expected) in [
749 (
750 FileSystemError::NotFound("readonly".into()),
751 FileSystemErrorClass::NotFound,
752 ),
753 (
754 FileSystemError::ReadOnly("not found".into()),
755 FileSystemErrorClass::ReadOnly,
756 ),
757 (
758 FileSystemError::IsADirectory("not empty".into()),
759 FileSystemErrorClass::IsADirectory,
760 ),
761 (
762 FileSystemError::NotADirectory("is a directory".into()),
763 FileSystemErrorClass::NotADirectory,
764 ),
765 (
766 FileSystemError::NotEmpty("not found".into()),
767 FileSystemErrorClass::NotEmpty,
768 ),
769 ] {
770 assert_eq!(classify_fs_error(&error), expected);
771 let wrapped = AgentLoopError::Internal(
772 anyhow::Error::new(error).context("readonly outer failure"),
773 );
774 assert_eq!(classify_fs_error(&wrapped), expected);
775 }
776 }
777
778 #[test]
779 fn filesystem_legacy_messages_preserve_routing_and_case_boundaries() {
780 for (message, expected) in [
781 (
782 "Cannot modify readonly file: /a",
783 FileSystemErrorClass::ReadOnly,
784 ),
785 (
786 "Cannot delete readonly file: /a",
787 FileSystemErrorClass::ReadOnly,
788 ),
789 (
790 "write target is a directory: /a",
791 FileSystemErrorClass::IsADirectory,
792 ),
793 (
794 "Path is not a directory: /a",
795 FileSystemErrorClass::NotADirectory,
796 ),
797 (
798 "workspace root is not a directory: /a",
799 FileSystemErrorClass::NotADirectory,
800 ),
801 ("Directory not found: /a", FileSystemErrorClass::NotFound),
802 (
803 "Directory is not empty. Use recursive=true to delete",
804 FileSystemErrorClass::NotEmpty,
805 ),
806 (
807 "Cannot delete root directory without recursive flag",
808 FileSystemErrorClass::NotEmpty,
809 ),
810 (
811 "recursive delete failed for /a: io",
812 FileSystemErrorClass::NotEmpty,
813 ),
814 ("readonly file not found", FileSystemErrorClass::ReadOnly),
815 ("file is read-only: /a", FileSystemErrorClass::Other),
816 ("NOT FOUND", FileSystemErrorClass::Other),
817 ("disk full", FileSystemErrorClass::Other),
818 ] {
819 assert_eq!(
820 classify_fs_error(&AgentLoopError::store(message)),
821 expected,
822 "{message}"
823 );
824 }
825 }
826
827 #[test]
828 fn typed_request_and_model_errors_preserve_identity_and_safe_user_payload() {
829 let context = || {
830 UserFacingErrorContext::default()
831 .with_provider("provider")
832 .with_model_id("context-model")
833 .with_retry_after(9)
834 };
835 let request = AgentLoopError::request_too_large("private payload");
836 assert_eq!(request.to_string(), "Request too large: private payload");
837 assert!(request.is_request_too_large());
838 assert!(!request.is_model_not_available());
839 assert_eq!(request.model_not_available_id(), None);
840 assert_eq!(
841 serde_json::to_value(request.user_facing_error(context())).unwrap(),
842 json!({"code":"request_too_large","fields":{"provider":"provider","model_id":"context-model"}})
843 );
844 assert_eq!(
845 request.user_facing_message(),
846 "The conversation has become too long for the model to process. Please start a new session or reduce the context size."
847 );
848 let model = AgentLoopError::model_not_available("gpt-99")
849 .with_provider("custom")
850 .with_provider("custom");
851 assert!(!model.is_request_too_large());
852 assert!(model.is_model_not_available());
853 assert_eq!(model.model_not_available_id(), Some("gpt-99"));
854 assert_eq!(model.to_string(), "Model not available: gpt-99");
855 assert_eq!(
856 model.user_facing_message(),
857 "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."
858 );
859 assert_eq!(
860 serde_json::to_value(model.user_facing_error(context())).unwrap(),
861 json!({"code":"model_unavailable","fields":{"provider":"provider","model_id":"gpt-99"}})
862 );
863 for other in [
864 AgentLoopError::llm("Request too large: Model not available: gpt-99"),
865 AgentLoopError::tool("failed"),
866 AgentLoopError::Cancelled,
867 ] {
868 assert!(!other.is_request_too_large());
869 assert!(!other.is_model_not_available());
870 assert_eq!(other.model_not_available_id(), None);
871 }
872 }
873
874 #[test]
875 fn semantic_kinds_override_conflicting_text_for_predicates_and_payloads() {
876 for (kind, message, predicates, code) in [
877 (
878 LlmErrorKind::Authentication,
879 "(429) rate limit (503)",
880 (false, true, false, false),
881 "provider_misconfigured",
882 ),
883 (
884 LlmErrorKind::QuotaExhausted,
885 "(401) (429) rate limit (503)",
886 (false, false, false, false),
887 "provider_quota_exhausted",
888 ),
889 (
890 LlmErrorKind::RateLimited,
891 "(401) (503) insufficient_quota",
892 (true, false, false, true),
893 "provider_rate_limited",
894 ),
895 (
896 LlmErrorKind::Unavailable,
897 "(401) (429) insufficient_quota",
898 (false, false, true, true),
899 "provider_unavailable",
900 ),
901 (
902 LlmErrorKind::InvalidRequest,
903 "opaque private failure",
904 (false, false, false, false),
905 "processing_error",
906 ),
907 ] {
908 let error = AgentLoopError::llm_kind(kind, message);
909 assert_eq!(error.llm_error_kind(), Some(kind));
910 assert_eq!(
911 (
912 error.is_rate_limited(),
913 error.is_auth_error(),
914 error.is_server_error(),
915 error.is_transient_llm_error()
916 ),
917 predicates,
918 "{kind:?}"
919 );
920 let mut fields = json!({"provider":"provider","model_id":"model"});
921 if kind == LlmErrorKind::RateLimited {
922 fields["retry_after"] = json!(12);
923 }
924 assert_eq!(
925 serde_json::to_value(
926 error.user_facing_error(
927 UserFacingErrorContext::default()
928 .with_provider("provider")
929 .with_model_id("model")
930 .with_retry_after(12)
931 )
932 )
933 .unwrap(),
934 json!({"code":code,"fields":fields})
935 );
936 }
937 }
938
939 #[test]
940 fn legacy_predicates_and_user_copy_use_independent_literal_cases() {
941 for (message, expected, copy) in [
942 (
943 "Anthropic API error (429): rate limit exceeded",
944 (true, false, false),
945 "Rate limited by the AI provider. Please wait a moment.",
946 ),
947 (
948 "Rate limit exceeded (after 2 retries)",
949 (true, false, false),
950 "Rate limited by the AI provider. Please wait a moment.",
951 ),
952 (
953 "too many requests",
954 (true, false, false),
955 "Rate limited by the AI provider. Please wait a moment.",
956 ),
957 (
958 "Anthropic API error (401): invalid api key",
959 (false, true, false),
960 "There is a misconfiguration with the AI provider. Please contact support.",
961 ),
962 (
963 "OpenAI API error (403): forbidden",
964 (false, true, false),
965 "There is a misconfiguration with the AI provider. Please contact support.",
966 ),
967 (
968 "Anthropic API error (500): internal server error",
969 (false, false, true),
970 "The AI provider is experiencing issues. Please try again shortly.",
971 ),
972 (
973 "OpenAI API error (503): service unavailable",
974 (false, false, true),
975 "The AI provider is experiencing issues. Please try again shortly.",
976 ),
977 (
978 "Failed to send request: connection refused",
979 (false, false, false),
980 "I encountered an error while processing your request. Please try again later.",
981 ),
982 ] {
983 let error = AgentLoopError::llm(message);
984 assert_eq!(
985 (
986 error.is_rate_limited(),
987 error.is_auth_error(),
988 error.is_server_error()
989 ),
990 expected,
991 "{message}"
992 );
993 assert_eq!(error.user_facing_message(), copy, "{message}");
994 }
995 for status in [502, 504, 529] {
996 assert!(AgentLoopError::llm(format!("error ({status})")).is_server_error());
997 }
998 let non_llm = AgentLoopError::tool("(401) (429) (503) rate limit");
999 assert_eq!(
1000 (
1001 non_llm.is_rate_limited(),
1002 non_llm.is_auth_error(),
1003 non_llm.is_server_error(),
1004 non_llm.is_transient_llm_error()
1005 ),
1006 (false, false, false, false)
1007 );
1008 }
1009
1010 #[test]
1011 fn provider_status_classification_covers_boundaries_and_quota_precedence() {
1012 for (status, expected) in [
1013 (200, LlmErrorKind::Other),
1014 (399, LlmErrorKind::Other),
1015 (400, LlmErrorKind::InvalidRequest),
1016 (401, LlmErrorKind::Authentication),
1017 (403, LlmErrorKind::Authentication),
1018 (404, LlmErrorKind::InvalidRequest),
1019 (408, LlmErrorKind::Unavailable),
1020 (409, LlmErrorKind::Unavailable),
1021 (429, LlmErrorKind::RateLimited),
1022 (499, LlmErrorKind::InvalidRequest),
1023 (500, LlmErrorKind::Unavailable),
1024 (501, LlmErrorKind::Other),
1025 (502, LlmErrorKind::Unavailable),
1026 (503, LlmErrorKind::Unavailable),
1027 (529, LlmErrorKind::Unavailable),
1028 (599, LlmErrorKind::Unavailable),
1029 (600, LlmErrorKind::Other),
1030 ] {
1031 assert_eq!(
1032 LlmErrorKind::from_provider_status(status, "opaque"),
1033 expected,
1034 "{status}"
1035 );
1036 }
1037 for message in [
1038 r#"{"error":{"type":"insufficient_quota"}}"#,
1039 r#"{"error":{"code":"credit_balance_exhausted"}}"#,
1040 r#"{"error":{"type":"usage_limit_reached"}}"#,
1041 "Your credit balance is too low to access the Anthropic API.",
1042 ] {
1043 for status in [400, 401, 429, 503] {
1044 assert_eq!(
1045 LlmErrorKind::from_provider_status(status, message),
1046 LlmErrorKind::QuotaExhausted,
1047 "{status}: {message}"
1048 );
1049 }
1050 }
1051 }
1052
1053 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"}}}"#;
1055
1056 #[test]
1057 fn attestation_gate_is_classified_apart_from_other_403s() {
1058 assert_eq!(
1059 LlmErrorKind::from_provider_status(403, ATTESTATION_BODY),
1060 LlmErrorKind::AttestationRequired
1061 );
1062 assert_eq!(
1065 LlmErrorKind::from_provider_status(429, ATTESTATION_BODY),
1066 LlmErrorKind::AttestationRequired
1067 );
1068 for body in [
1069 r#"{"error":{"message":"Invalid credentials","code":403}}"#,
1070 r#"{"error":{"message":"Insufficient credits","code":403,"metadata":{"routing_funnel":[]}}}"#,
1071 "opaque",
1072 ] {
1073 assert_ne!(
1074 LlmErrorKind::from_provider_status(403, body),
1075 LlmErrorKind::AttestationRequired,
1076 "{body}"
1077 );
1078 }
1079 assert_eq!(
1081 LlmErrorKind::from_provider_status(
1082 403,
1083 r#"{"error":{"message":"insufficient_quota; requires you to complete the following before use"}}"#
1084 ),
1085 LlmErrorKind::QuotaExhausted
1086 );
1087 }
1088
1089 #[test]
1090 fn attestation_gate_reaches_the_reader_with_the_types_and_the_confirm_url() {
1091 let error = AgentLoopError::llm_kind(
1092 LlmErrorKind::AttestationRequired,
1093 format!("OpenAI Responses API error (403): {ATTESTATION_BODY}"),
1094 )
1095 .with_provider("openrouter");
1096 assert!(!error.is_auth_error());
1098 assert!(!error.is_transient_llm_error());
1099 assert_eq!(
1100 serde_json::to_value(
1101 error.user_facing_error(
1102 UserFacingErrorContext::default()
1103 .with_provider("openrouter")
1104 .with_model_id("meta/muse-spark-1.3-contributor")
1105 )
1106 )
1107 .unwrap(),
1108 json!({
1109 "code": "provider_attestation_required",
1110 "fields": {
1111 "provider": "openrouter",
1112 "model_id": "meta/muse-spark-1.3-contributor",
1113 "missing_types": ["age_18plus"],
1114 "confirm_url": "https://openrouter.ai/settings/preferences",
1115 }
1116 })
1117 );
1118 assert_eq!(
1119 error.user_facing_message(),
1120 "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."
1121 );
1122 }
1123
1124 #[test]
1125 fn untyped_attestation_bodies_still_route_off_the_403_misconfiguration_copy() {
1126 let error = AgentLoopError::llm(format!(
1129 "provider 'openrouter': OpenAI Responses API error (403): {ATTESTATION_BODY}"
1130 ));
1131 assert_eq!(
1132 error
1133 .user_facing_error(UserFacingErrorContext::default())
1134 .code,
1135 "provider_attestation_required"
1136 );
1137 }
1138
1139 #[test]
1140 fn attestation_parsing_covers_multiple_types_escaped_bodies_and_a_missing_url() {
1141 let requirement = |body: &str| {
1142 parse_attestation_requirement(body).unwrap_or_else(|| panic!("no gate in {body}"))
1143 };
1144
1145 let multiple = requirement(
1147 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"]}}}"#,
1148 );
1149 assert_eq!(multiple.missing_types, ["age_18plus", "identity_verified"]);
1150 assert_eq!(
1151 multiple.confirm_url,
1152 "https://openrouter.ai/settings/preferences"
1153 );
1154
1155 let escaped = requirement(
1157 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\"]}}}"}"#,
1158 );
1159 assert_eq!(escaped.missing_types, ["age_18plus"]);
1160 assert_eq!(escaped.confirm_url, "https://openrouter.ai/settings/gates");
1161
1162 let no_url = requirement(
1165 r#"{"error":{"message":"This model requires you to complete the following before use: 18+ age confirmation.","metadata":{"missing_attestation_types":["age_18plus"]}}}"#,
1166 );
1167 assert_eq!(
1168 no_url.confirm_url,
1169 "https://openrouter.ai/settings/preferences"
1170 );
1171
1172 let sentence_only = requirement(
1174 "This model requires you to complete the following before use: 18+ age confirmation. Confirm at https://openrouter.ai/settings/preferences",
1175 );
1176 assert!(sentence_only.missing_types.is_empty());
1177 assert_eq!(
1178 sentence_only.confirm_url,
1179 "https://openrouter.ai/settings/preferences"
1180 );
1181 assert_eq!(
1184 AgentLoopError::llm_kind(
1185 LlmErrorKind::AttestationRequired,
1186 "This model requires you to complete the following before use: a confirmation."
1187 )
1188 .user_facing_message(),
1189 "The AI provider account has not completed a confirmation this model requires. Complete it at https://openrouter.ai/settings/preferences, then try again."
1190 );
1191
1192 assert_eq!(
1194 requirement(&format!(
1195 "POST https://openrouter.ai/api/v1/responses failed: {ATTESTATION_BODY}"
1196 ))
1197 .confirm_url,
1198 "https://openrouter.ai/settings/preferences"
1199 );
1200
1201 for body in [
1202 r#"{"error":{"message":"Invalid credentials"}}"#,
1203 r#"{"error":{"metadata":{"missing_attestation_types":[]}}}"#,
1204 "",
1205 ] {
1206 assert!(parse_attestation_requirement(body).is_none(), "{body}");
1207 }
1208 }
1209
1210 #[test]
1211 fn a_hostile_attestation_payload_cannot_choose_how_much_reaches_the_viewer() {
1212 let types = (0..40)
1213 .map(|index| format!(r#""gate_{index}""#))
1214 .collect::<Vec<_>>()
1215 .join(",");
1216 let long_type = "x".repeat(65);
1217 let long_url = format!("https://evil.example/{}", "a".repeat(400));
1218 let requirement = parse_attestation_requirement(&format!(
1219 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}]}}}}}}"#
1220 ))
1221 .expect("gate recognized");
1222
1223 assert_eq!(requirement.missing_types.len(), 8);
1225 assert_eq!(requirement.missing_types[0], "gate_0");
1226 assert_eq!(
1228 requirement.confirm_url,
1229 "https://openrouter.ai/settings/preferences"
1230 );
1231
1232 for scheme in [
1234 "javascript:alert(1)",
1235 "data:text/html,<script>",
1236 "file:///etc/passwd",
1237 ] {
1238 assert_eq!(
1239 parse_attestation_requirement(&format!(
1240 "This model requires you to complete the following before use: a gate. Confirm at {scheme}"
1241 ))
1242 .expect("gate recognized")
1243 .confirm_url,
1244 "https://openrouter.ai/settings/preferences",
1245 "{scheme}"
1246 );
1247 }
1248 }
1249
1250 #[test]
1251 fn provider_text_classification_uses_independent_keywords_and_precedence() {
1252 for (message, expected) in [
1253 ("ThrottlingException", LlmErrorKind::RateLimited),
1254 ("TooManyRequestsException", LlmErrorKind::RateLimited),
1255 ("RATE LIMIT", LlmErrorKind::RateLimited),
1256 ("too many requests", LlmErrorKind::RateLimited),
1257 ("AccessDeniedException", LlmErrorKind::Authentication),
1258 ("UnrecognizedClientException", LlmErrorKind::Authentication),
1259 ("ExpiredTokenException", LlmErrorKind::Authentication),
1260 ("InvalidSignatureException", LlmErrorKind::Authentication),
1261 ("unauthorized", LlmErrorKind::Authentication),
1262 ("ServiceUnavailableException", LlmErrorKind::Unavailable),
1263 ("service unavailable", LlmErrorKind::Unavailable),
1264 ("InternalServerException", LlmErrorKind::Unavailable),
1265 ("ModelNotReadyException", LlmErrorKind::Unavailable),
1266 (
1267 "usage_limit_reached; resets_at=1783767823; throttlingexception",
1268 LlmErrorKind::QuotaExhausted,
1269 ),
1270 ("something else entirely", LlmErrorKind::Other),
1271 ] {
1272 assert_eq!(
1273 LlmErrorKind::from_error_text(message),
1274 expected,
1275 "{message}"
1276 );
1277 }
1278 }
1279
1280 #[test]
1281 fn provider_prefix_preserves_kind_and_retry_metadata_without_duplication() {
1282 let metadata = crate::llm_retry::RetryMetadata {
1283 attempts: 2,
1284 total_retry_wait: std::time::Duration::from_millis(1234),
1285 ..Default::default()
1286 };
1287 let error = AgentLoopError::llm_kind(LlmErrorKind::Unavailable, "network failure")
1288 .with_retry_metadata(&metadata)
1289 .with_provider("custom")
1290 .with_provider("custom");
1291 assert_eq!(error.llm_retry_attempts(), 2);
1292 assert!(error.llm_retry_handled());
1293 let AgentLoopError::Llm(error) = error else {
1294 panic!("lost LLM variant")
1295 };
1296 assert_eq!(
1297 serde_json::to_value(error).unwrap(),
1298 json!({"kind":"unavailable","message":"provider 'custom': network failure","retry_attempts":2,"retry_wait_ms":1234,"retry_handled":true})
1299 );
1300 let legacy: LlmError =
1301 serde_json::from_value(json!({"kind":"other","message":"legacy"})).unwrap();
1302 assert_eq!(
1303 serde_json::to_value(legacy).unwrap(),
1304 json!({"kind":"other","message":"legacy","retry_attempts":0,"retry_wait_ms":0,"retry_handled":false})
1305 );
1306 let non_llm = AgentLoopError::Cancelled
1307 .with_retry_metadata(&metadata)
1308 .with_provider("custom");
1309 assert!(matches!(non_llm, AgentLoopError::Cancelled));
1310 assert_eq!(non_llm.llm_retry_attempts(), 0);
1311 assert!(!non_llm.llm_retry_handled());
1312 }
1313
1314 #[test]
1315 fn billing_pressure_preserves_typed_payload_and_safe_user_fields() {
1316 let kind = LlmErrorKind::BillingPressure {
1317 reason: BillingPressureReason::InFlightBudgetExhausted,
1318 retry_after_secs: Some(120),
1319 };
1320 let error = AgentLoopError::llm_kind(kind, "private provider body");
1321 assert_eq!(error.llm_error_kind(), Some(kind));
1322 assert!(!error.is_transient_llm_error());
1323 assert_eq!(
1324 serde_json::to_value(
1325 error.user_facing_error(
1326 UserFacingErrorContext::default()
1327 .with_provider("openrouter")
1328 .with_model_id("vendor/model")
1329 )
1330 )
1331 .unwrap(),
1332 json!({
1333 "code": "provider_rate_limited",
1334 "fields": {
1335 "provider": "openrouter",
1336 "model_id": "vendor/model",
1337 "retry_after": 120,
1338 }
1339 })
1340 );
1341 let AgentLoopError::Llm(error) = error else {
1342 panic!("lost LLM variant")
1343 };
1344 assert_eq!(
1345 serde_json::to_value(error.kind).unwrap(),
1346 json!({
1347 "billing_pressure": {
1348 "reason": "in_flight_budget_exhausted",
1349 "retry_after_secs": 120,
1350 }
1351 })
1352 );
1353
1354 let exhausted = AgentLoopError::llm_kind(
1355 LlmErrorKind::BillingPressure {
1356 reason: BillingPressureReason::InsufficientCredits,
1357 retry_after_secs: None,
1358 },
1359 "private provider body",
1360 );
1361 assert_eq!(
1362 exhausted
1363 .user_facing_error(UserFacingErrorContext::default())
1364 .code,
1365 "provider_quota_exhausted"
1366 );
1367 }
1368
1369 #[test]
1370 fn missing_model_and_iteration_limits_have_complete_safe_payloads() {
1371 let missing = AgentLoopError::model_not_configured();
1372 assert!(missing.is_non_retryable());
1373 assert_eq!(
1374 missing.user_facing_message(),
1375 "No model is configured for this chat. Choose a model or configure a default model, then try again."
1376 );
1377 assert_eq!(
1378 serde_json::to_value(missing.user_facing_error(UserFacingErrorContext::default()))
1379 .unwrap(),
1380 json!({"code":"model_not_configured"})
1381 );
1382 assert_eq!(
1383 serde_json::to_value(
1384 AgentLoopError::MaxIterationsReached(7)
1385 .user_facing_error(UserFacingErrorContext::default())
1386 )
1387 .unwrap(),
1388 json!({"code":"max_iterations","fields":{"max_iterations":7}})
1389 );
1390 }
1391
1392 #[test]
1393 fn store_adapter_preserves_success_and_exact_error_variant_and_message() {
1394 let success: std::result::Result<Vec<String>, String> =
1395 Ok(vec!["first".into(), "second".into()]);
1396 assert_eq!(success.store_err().unwrap(), ["first", "second"]);
1397 let failure: std::result::Result<(), std::io::Error> =
1398 Err(std::io::Error::other("db unavailable"));
1399 let error = failure.store_err().unwrap_err();
1400 assert_eq!(error.to_string(), "Message store error: db unavailable");
1401 assert!(matches!(error,AgentLoopError::MessageStore(message) if message=="db unavailable"));
1402 }
1403
1404 #[test]
1405 fn json_helpers_preserve_structures_and_apply_documented_error_defaults() {
1406 assert_eq!(json_val(&vec![1, 2, 3]), json!([1, 2, 3]));
1407 assert_eq!(from_json::<Vec<String>>(json!(["a", "b"])), ["a", "b"]);
1408 assert_eq!(from_json::<i32>(json!("not a number")), 0);
1409 struct Fails;
1410 impl Serialize for Fails {
1411 fn serialize<S: serde::Serializer>(
1412 &self,
1413 _: S,
1414 ) -> std::result::Result<S::Ok, S::Error> {
1415 Err(serde::ser::Error::custom("synthetic serialization failure"))
1416 }
1417 }
1418 assert_eq!(json_val(&Fails), serde_json::Value::Null);
1419 }
1420}