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