1use crate::typed_id::{AgentId, HarnessId, SessionId};
7use crate::user_facing_error::{
8 UserFacingError, UserFacingErrorContext, classify_runtime_error_message,
9 codes as user_facing_error_codes, is_provider_quota_message,
10};
11use serde::{Deserialize, Serialize, de::DeserializeOwned};
12use thiserror::Error;
13
14pub type Result<T> = std::result::Result<T, AgentLoopError>;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum LlmErrorKind {
25 Authentication,
27 QuotaExhausted,
30 RateLimited,
32 Unavailable,
34 InvalidRequest,
36 Other,
38}
39
40impl LlmErrorKind {
41 pub fn from_provider_status(status: u16, body: &str) -> Self {
48 if is_provider_quota_message(body) {
49 return LlmErrorKind::QuotaExhausted;
50 }
51 match status {
52 401 | 403 => LlmErrorKind::Authentication,
53 429 => LlmErrorKind::RateLimited,
54 408 | 500..=599 => LlmErrorKind::Unavailable,
55 400..=499 => LlmErrorKind::InvalidRequest,
56 _ => LlmErrorKind::Other,
57 }
58 }
59
60 pub fn from_error_text(text: &str) -> Self {
63 if is_provider_quota_message(text) {
64 return LlmErrorKind::QuotaExhausted;
65 }
66 let lower = text.to_ascii_lowercase();
67 if lower.contains("throttlingexception")
68 || lower.contains("toomanyrequestsexception")
69 || lower.contains("rate limit")
70 || lower.contains("too many requests")
71 {
72 return LlmErrorKind::RateLimited;
73 }
74 if lower.contains("accessdeniedexception")
75 || lower.contains("unrecognizedclientexception")
76 || lower.contains("expiredtokenexception")
77 || lower.contains("invalidsignatureexception")
78 || lower.contains("unauthorized")
79 {
80 return LlmErrorKind::Authentication;
81 }
82 if lower.contains("serviceunavailable")
83 || lower.contains("service unavailable")
84 || lower.contains("internalserverexception")
85 || lower.contains("modelnotreadyexception")
86 {
87 return LlmErrorKind::Unavailable;
88 }
89 LlmErrorKind::Other
90 }
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct LlmError {
96 pub kind: LlmErrorKind,
97 pub message: String,
98}
99
100impl std::fmt::Display for LlmError {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 f.write_str(&self.message)
103 }
104}
105
106#[derive(Debug, Error)]
108pub enum AgentLoopError {
109 #[error("LLM error: {0}")]
111 Llm(LlmError),
112
113 #[error("Request too large: {0}")]
116 RequestTooLarge(String),
117
118 #[error("Model not available: {0}")]
121 ModelNotAvailable(String),
122
123 #[error("Tool execution error: {0}")]
125 ToolExecution(String),
126
127 #[error("Message store error: {0}")]
129 MessageStore(String),
130
131 #[error("Event emission error: {0}")]
133 EventEmission(String),
134
135 #[error("Configuration error: {0}")]
137 Configuration(String),
138
139 #[error("Max iterations ({0}) reached")]
141 MaxIterationsReached(usize),
142
143 #[error("Loop cancelled")]
145 Cancelled,
146
147 #[error("No messages to process")]
149 NoMessages,
150
151 #[error("Agent not found: {0}")]
153 AgentNotFound(AgentId),
154
155 #[error("Harness not found: {0}")]
157 HarnessNotFound(HarnessId),
158
159 #[error("Session not found: {0}")]
161 SessionNotFound(SessionId),
162
163 #[error("Internal error: {0}")]
165 Internal(#[from] anyhow::Error),
166
167 #[error(
169 "No driver registered for provider type '{0}'. Make sure the driver is registered at startup."
170 )]
171 DriverNotRegistered(String),
172}
173
174impl AgentLoopError {
175 pub fn llm(msg: impl Into<String>) -> Self {
178 AgentLoopError::Llm(LlmError {
179 kind: LlmErrorKind::Other,
180 message: msg.into(),
181 })
182 }
183
184 pub fn llm_kind(kind: LlmErrorKind, msg: impl Into<String>) -> Self {
186 AgentLoopError::Llm(LlmError {
187 kind,
188 message: msg.into(),
189 })
190 }
191
192 pub fn llm_error_kind(&self) -> Option<LlmErrorKind> {
194 match self {
195 AgentLoopError::Llm(err) => Some(err.kind),
196 _ => None,
197 }
198 }
199
200 pub fn tool(msg: impl Into<String>) -> Self {
202 AgentLoopError::ToolExecution(msg.into())
203 }
204
205 pub fn store(msg: impl Into<String>) -> Self {
207 AgentLoopError::MessageStore(msg.into())
208 }
209
210 pub fn event(msg: impl Into<String>) -> Self {
212 AgentLoopError::EventEmission(msg.into())
213 }
214
215 pub fn config(msg: impl Into<String>) -> Self {
217 AgentLoopError::Configuration(msg.into())
218 }
219
220 pub fn agent_not_found(agent_id: AgentId) -> Self {
222 AgentLoopError::AgentNotFound(agent_id)
223 }
224
225 pub fn harness_not_found(harness_id: HarnessId) -> Self {
227 AgentLoopError::HarnessNotFound(harness_id)
228 }
229
230 pub fn session_not_found(session_id: SessionId) -> Self {
232 AgentLoopError::SessionNotFound(session_id)
233 }
234
235 pub fn driver_not_registered(provider_type: impl Into<String>) -> Self {
237 AgentLoopError::DriverNotRegistered(provider_type.into())
238 }
239
240 pub fn request_too_large(msg: impl Into<String>) -> Self {
242 AgentLoopError::RequestTooLarge(msg.into())
243 }
244
245 pub fn model_not_available(model_id: impl Into<String>) -> Self {
247 AgentLoopError::ModelNotAvailable(model_id.into())
248 }
249
250 pub fn is_request_too_large(&self) -> bool {
252 matches!(self, AgentLoopError::RequestTooLarge(_))
253 }
254
255 pub fn is_model_not_available(&self) -> bool {
257 matches!(self, AgentLoopError::ModelNotAvailable(_))
258 }
259
260 pub fn model_not_available_id(&self) -> Option<&str> {
262 match self {
263 AgentLoopError::ModelNotAvailable(id) => Some(id),
264 _ => None,
265 }
266 }
267
268 pub fn is_rate_limited(&self) -> bool {
271 match self {
272 AgentLoopError::Llm(err) => match err.kind {
273 LlmErrorKind::RateLimited => true,
274 LlmErrorKind::Other => {
275 let msg_lower = err.message.to_ascii_lowercase();
276 msg_lower.contains("(429)")
277 || msg_lower.contains("rate limit")
278 || msg_lower.contains("too many requests")
279 }
280 _ => false,
281 },
282 _ => false,
283 }
284 }
285
286 pub fn is_auth_error(&self) -> bool {
288 match self {
289 AgentLoopError::Llm(err) => match err.kind {
290 LlmErrorKind::Authentication => true,
291 LlmErrorKind::Other => {
292 err.message.contains("(401)") || err.message.contains("(403)")
293 }
294 _ => false,
295 },
296 _ => false,
297 }
298 }
299
300 pub fn is_server_error(&self) -> bool {
302 match self {
303 AgentLoopError::Llm(err) => match err.kind {
304 LlmErrorKind::Unavailable => true,
305 LlmErrorKind::Other => {
306 let msg = &err.message;
307 msg.contains("(500)")
308 || msg.contains("(502)")
309 || msg.contains("(503)")
310 || msg.contains("(504)")
311 || msg.contains("(529)")
312 }
313 _ => false,
314 },
315 _ => false,
316 }
317 }
318
319 pub fn is_non_retryable(&self) -> bool {
330 match self {
331 AgentLoopError::AgentNotFound(_)
333 | AgentLoopError::HarnessNotFound(_)
334 | AgentLoopError::SessionNotFound(_)
335 | AgentLoopError::NoMessages => true,
336
337 AgentLoopError::Configuration(_) | AgentLoopError::DriverNotRegistered(_) => true,
339
340 AgentLoopError::MessageStore(msg) => msg.to_ascii_lowercase().contains("not found"),
342
343 _ => false,
345 }
346 }
347
348 pub fn user_facing_message(&self) -> String {
350 self.user_facing_error(UserFacingErrorContext::default())
351 .fallback_message()
352 }
353
354 pub fn user_facing_error(&self, context: UserFacingErrorContext) -> UserFacingError {
356 match self {
357 AgentLoopError::ModelNotAvailable(model_id) => {
358 UserFacingError::new(user_facing_error_codes::MODEL_UNAVAILABLE)
359 .with_field("model_id", model_id)
360 .with_optional_field("provider", context.provider)
361 }
362 AgentLoopError::RequestTooLarge(_) => {
363 UserFacingError::new(user_facing_error_codes::REQUEST_TOO_LARGE)
364 .with_optional_field("provider", context.provider)
365 .with_optional_field("model_id", context.model_id)
366 }
367 AgentLoopError::MaxIterationsReached(max_iterations) => {
368 UserFacingError::new(user_facing_error_codes::MAX_ITERATIONS)
369 .with_field("max_iterations", max_iterations)
370 }
371 AgentLoopError::Llm(err) => {
372 let code = match err.kind {
376 LlmErrorKind::Authentication => {
377 Some(user_facing_error_codes::PROVIDER_MISCONFIGURED)
378 }
379 LlmErrorKind::QuotaExhausted => {
380 Some(user_facing_error_codes::PROVIDER_QUOTA_EXHAUSTED)
381 }
382 LlmErrorKind::RateLimited => {
383 Some(user_facing_error_codes::PROVIDER_RATE_LIMITED)
384 }
385 LlmErrorKind::Unavailable => {
386 Some(user_facing_error_codes::PROVIDER_UNAVAILABLE)
387 }
388 LlmErrorKind::InvalidRequest | LlmErrorKind::Other => None,
389 };
390 match code {
391 Some(code) => {
392 let error = UserFacingError::new(code)
393 .with_optional_field("provider", context.provider)
394 .with_optional_field("model_id", context.model_id);
395 if code == user_facing_error_codes::PROVIDER_RATE_LIMITED {
396 error.with_optional_field("retry_after", context.retry_after)
397 } else {
398 error
399 }
400 }
401 None => classify_runtime_error_message(&err.message, &context),
402 }
403 }
404 _ => UserFacingError::new(user_facing_error_codes::PROCESSING_ERROR)
405 .with_optional_field("provider", context.provider)
406 .with_optional_field("model_id", context.model_id),
407 }
408 }
409}
410
411pub trait StoreResultExt<T> {
427 fn store_err(self) -> Result<T>;
428}
429
430impl<T, E: std::fmt::Display> StoreResultExt<T> for std::result::Result<T, E> {
431 fn store_err(self) -> Result<T> {
432 self.map_err(|e| AgentLoopError::store(e.to_string()))
433 }
434}
435
436pub fn json_val<T: Serialize>(value: &T) -> serde_json::Value {
447 serde_json::to_value(value).unwrap_or_default()
448}
449
450pub fn from_json<T: DeserializeOwned + Default>(value: serde_json::Value) -> T {
457 serde_json::from_value(value).unwrap_or_default()
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463
464 #[test]
465 fn test_is_request_too_large_returns_true_for_typed_error() {
466 let err = AgentLoopError::request_too_large("context length exceeded");
467 assert!(err.is_request_too_large());
468 }
469
470 #[test]
471 fn test_is_request_too_large_returns_false_for_llm_error() {
472 let err = AgentLoopError::llm("OpenAI API error (500): Internal server error");
473 assert!(!err.is_request_too_large());
474 }
475
476 #[test]
477 fn test_is_request_too_large_returns_false_for_other_errors() {
478 let err = AgentLoopError::ToolExecution("some error".to_string());
479 assert!(!err.is_request_too_large());
480
481 let err = AgentLoopError::Cancelled;
482 assert!(!err.is_request_too_large());
483 }
484
485 #[test]
486 fn test_request_too_large_error_preserves_message() {
487 let original_msg = "OpenAI API error (429): Request too large for gpt-4";
488 let err = AgentLoopError::request_too_large(original_msg);
489 assert_eq!(
490 err.to_string(),
491 format!("Request too large: {}", original_msg)
492 );
493 }
494
495 #[test]
496 fn test_is_model_not_available_returns_true_for_typed_error() {
497 let err = AgentLoopError::model_not_available("claude-sonnet-4-6-20260217");
498 assert!(err.is_model_not_available());
499 assert_eq!(
500 err.model_not_available_id(),
501 Some("claude-sonnet-4-6-20260217")
502 );
503 }
504
505 #[test]
506 fn test_is_model_not_available_returns_false_for_llm_error() {
507 let err = AgentLoopError::llm("some error");
508 assert!(!err.is_model_not_available());
509 assert_eq!(err.model_not_available_id(), None);
510 }
511
512 #[test]
513 fn test_model_not_available_error_display() {
514 let err = AgentLoopError::model_not_available("gpt-99");
515 assert_eq!(err.to_string(), "Model not available: gpt-99");
516 }
517
518 #[test]
519 fn test_is_rate_limited_detects_429() {
520 let err = AgentLoopError::llm("Anthropic API error (429): rate limit exceeded");
521 assert!(err.is_rate_limited());
522 }
523
524 #[test]
525 fn test_is_rate_limited_detects_rate_limit_keyword() {
526 let err =
527 AgentLoopError::llm("Rate limit exceeded (after 2 retries, last error: too many)");
528 assert!(err.is_rate_limited());
529 }
530
531 #[test]
532 fn test_is_rate_limited_false_for_server_error() {
533 let err = AgentLoopError::llm("Anthropic API error (500): internal server error");
534 assert!(!err.is_rate_limited());
535 }
536
537 #[test]
538 fn test_is_auth_error_detects_401() {
539 let err = AgentLoopError::llm("Anthropic API error (401): invalid api key");
540 assert!(err.is_auth_error());
541 }
542
543 #[test]
544 fn test_is_auth_error_detects_403() {
545 let err = AgentLoopError::llm("OpenAI API error (403): forbidden");
546 assert!(err.is_auth_error());
547 }
548
549 #[test]
550 fn test_is_server_error_detects_500() {
551 let err = AgentLoopError::llm("Anthropic API error (500): internal server error");
552 assert!(err.is_server_error());
553 }
554
555 #[test]
556 fn test_is_server_error_detects_503() {
557 let err = AgentLoopError::llm("OpenAI API error (503): service unavailable");
558 assert!(err.is_server_error());
559 }
560
561 #[test]
562 fn test_user_facing_message_rate_limited() {
563 let err = AgentLoopError::llm("Anthropic API error (429): rate limit exceeded");
564 assert_eq!(
565 err.user_facing_message(),
566 "Rate limited by the AI provider. Please wait a moment."
567 );
568 }
569
570 #[test]
571 fn test_user_facing_message_auth_error() {
572 let err = AgentLoopError::llm("Anthropic API error (401): invalid api key");
573 assert_eq!(
574 err.user_facing_message(),
575 "There is a misconfiguration with the AI provider. Please contact support."
576 );
577 }
578
579 #[test]
580 fn test_user_facing_message_server_error() {
581 let err = AgentLoopError::llm("Anthropic API error (500): internal server error");
582 assert_eq!(
583 err.user_facing_message(),
584 "The AI provider is experiencing issues. Please try again shortly."
585 );
586 }
587
588 #[test]
589 fn test_user_facing_message_generic_fallback() {
590 let err = AgentLoopError::llm("Failed to send request: connection refused");
591 assert_eq!(
592 err.user_facing_message(),
593 "I encountered an error while processing your request. Please try again later."
594 );
595 }
596
597 #[test]
598 fn test_user_facing_message_model_not_available() {
599 let err = AgentLoopError::model_not_available("gpt-99");
600 assert!(err.user_facing_message().contains("gpt-99"));
601 assert!(err.user_facing_message().contains("not available"));
602 }
603
604 #[test]
605 fn test_user_facing_message_request_too_large() {
606 let err = AgentLoopError::request_too_large("context length exceeded");
607 assert!(err.user_facing_message().contains("too long"));
608 }
609
610 #[test]
611 fn test_user_facing_error_model_not_available_includes_model_id() {
612 let err = AgentLoopError::model_not_available("gpt-99");
613 let user_error = err.user_facing_error(UserFacingErrorContext::default());
614
615 assert_eq!(user_error.code, user_facing_error_codes::MODEL_UNAVAILABLE);
616 assert_eq!(
617 user_error.fields.get("model_id"),
618 Some(&serde_json::Value::String("gpt-99".to_string()))
619 );
620 }
621
622 #[test]
623 fn test_user_facing_error_rate_limited_includes_provider_context() {
624 let err = AgentLoopError::llm("Anthropic API error (429): rate limit exceeded");
625 let user_error = err.user_facing_error(
626 UserFacingErrorContext::default()
627 .with_provider("anthropic")
628 .with_model_id("claude-sonnet-4-5")
629 .with_retry_after(12),
630 );
631
632 assert_eq!(
633 user_error.code,
634 user_facing_error_codes::PROVIDER_RATE_LIMITED
635 );
636 assert_eq!(
637 user_error.fields.get("provider"),
638 Some(&serde_json::Value::String("anthropic".to_string()))
639 );
640 assert_eq!(
641 user_error.fields.get("model_id"),
642 Some(&serde_json::Value::String("claude-sonnet-4-5".to_string()))
643 );
644 assert_eq!(
645 user_error.fields.get("retry_after"),
646 Some(&serde_json::json!(12))
647 );
648 }
649
650 #[test]
651 fn test_llm_error_kind_from_provider_status() {
652 assert_eq!(
653 LlmErrorKind::from_provider_status(401, "invalid x-api-key"),
654 LlmErrorKind::Authentication
655 );
656 assert_eq!(
657 LlmErrorKind::from_provider_status(403, "forbidden"),
658 LlmErrorKind::Authentication
659 );
660 assert_eq!(
661 LlmErrorKind::from_provider_status(429, "rate limit exceeded"),
662 LlmErrorKind::RateLimited
663 );
664 assert_eq!(
666 LlmErrorKind::from_provider_status(
667 429,
668 "{\"error\":{\"type\":\"insufficient_quota\"}}"
669 ),
670 LlmErrorKind::QuotaExhausted
671 );
672 assert_eq!(
674 LlmErrorKind::from_provider_status(
675 400,
676 "Your credit balance is too low to access the Anthropic API."
677 ),
678 LlmErrorKind::QuotaExhausted
679 );
680 assert_eq!(
681 LlmErrorKind::from_provider_status(529, "overloaded"),
682 LlmErrorKind::Unavailable
683 );
684 assert_eq!(
685 LlmErrorKind::from_provider_status(503, "unavailable"),
686 LlmErrorKind::Unavailable
687 );
688 assert_eq!(
689 LlmErrorKind::from_provider_status(400, "bad request"),
690 LlmErrorKind::InvalidRequest
691 );
692 }
693
694 #[test]
695 fn test_llm_error_kind_from_error_text_bedrock() {
696 assert_eq!(
697 LlmErrorKind::from_error_text("ThrottlingException: Too many requests"),
698 LlmErrorKind::RateLimited
699 );
700 assert_eq!(
701 LlmErrorKind::from_error_text("AccessDeniedException: not authorized"),
702 LlmErrorKind::Authentication
703 );
704 assert_eq!(
705 LlmErrorKind::from_error_text("ServiceUnavailableException"),
706 LlmErrorKind::Unavailable
707 );
708 assert_eq!(
709 LlmErrorKind::from_error_text("something else entirely"),
710 LlmErrorKind::Other
711 );
712 }
713
714 #[test]
715 fn test_user_facing_error_prefers_semantic_kind() {
716 let err = AgentLoopError::llm_kind(
719 LlmErrorKind::QuotaExhausted,
720 "OpenAI API error (429): insufficient_quota",
721 );
722 let user_error =
723 err.user_facing_error(UserFacingErrorContext::default().with_provider("openai"));
724 assert_eq!(
725 user_error.code,
726 user_facing_error_codes::PROVIDER_QUOTA_EXHAUSTED
727 );
728 assert_eq!(
729 user_error.fields.get("provider"),
730 Some(&serde_json::Value::String("openai".to_string()))
731 );
732
733 let err = AgentLoopError::llm_kind(LlmErrorKind::Authentication, "bad key");
734 assert_eq!(
735 err.user_facing_error(UserFacingErrorContext::default())
736 .code,
737 user_facing_error_codes::PROVIDER_MISCONFIGURED
738 );
739
740 let err = AgentLoopError::llm_kind(LlmErrorKind::RateLimited, "slow down");
741 let user_error =
742 err.user_facing_error(UserFacingErrorContext::default().with_retry_after(5));
743 assert_eq!(
744 user_error.code,
745 user_facing_error_codes::PROVIDER_RATE_LIMITED
746 );
747 assert_eq!(user_error.fields.get("retry_after"), Some(&json_val(&5)));
748
749 let err = AgentLoopError::llm_kind(LlmErrorKind::Unavailable, "overloaded");
750 assert_eq!(
751 err.user_facing_error(UserFacingErrorContext::default())
752 .code,
753 user_facing_error_codes::PROVIDER_UNAVAILABLE
754 );
755 }
756
757 #[test]
758 fn test_semantic_kind_drives_predicates() {
759 assert!(AgentLoopError::llm_kind(LlmErrorKind::RateLimited, "x").is_rate_limited());
760 assert!(AgentLoopError::llm_kind(LlmErrorKind::Authentication, "x").is_auth_error());
761 assert!(AgentLoopError::llm_kind(LlmErrorKind::Unavailable, "x").is_server_error());
762 assert!(AgentLoopError::llm("error (429)").is_rate_limited());
764 assert!(
765 !AgentLoopError::llm_kind(LlmErrorKind::Authentication, "error (429)")
766 .is_rate_limited()
767 );
768 }
769
770 #[test]
771 fn test_store_result_ext_ok() {
772 let result: std::result::Result<i32, String> = Ok(42);
773 assert_eq!(result.store_err().unwrap(), 42);
774 }
775
776 #[test]
777 fn test_store_result_ext_err() {
778 let result: std::result::Result<i32, String> = Err("db error".to_string());
779 let err = result.store_err().unwrap_err();
780 assert!(matches!(err, AgentLoopError::MessageStore(_)));
781 assert!(err.to_string().contains("db error"));
782 }
783
784 #[test]
785 fn test_json_val() {
786 let v = json_val(&vec![1, 2, 3]);
787 assert_eq!(v, serde_json::json!([1, 2, 3]));
788 }
789
790 #[test]
791 fn test_from_json() {
792 let v = serde_json::json!(["a", "b"]);
793 let result: Vec<String> = from_json(v);
794 assert_eq!(result, vec!["a", "b"]);
795 }
796
797 #[test]
798 fn test_from_json_default_on_mismatch() {
799 let v = serde_json::json!("not a number");
800 let result: i32 = from_json(v);
801 assert_eq!(result, 0);
802 }
803}