1use serde::{Deserialize, Serialize};
53
54use crate::kernel_boundary::{ProposedContent, TraceLink};
55
56#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum BackendError {
74 InvalidRequest { message: String },
76 ExecutionFailed { message: String },
78 Unavailable { message: String },
80 BudgetExceeded { resource: String, limit: String },
82 ContractFailed { contract: String, message: String },
84 UnsupportedCapability { capability: BackendCapability },
86 AdapterError { message: String },
88 RecallError { message: String },
90 Timeout {
92 deadline_ms: u64,
94 elapsed_ms: u64,
96 },
97 CircuitOpen {
99 backend: String,
101 retry_after_ms: Option<u64>,
103 },
104 Retried {
106 message: String,
108 attempts: usize,
110 was_transient: bool,
112 },
113 Other { message: String },
115}
116
117impl std::fmt::Display for BackendError {
118 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119 match self {
120 Self::InvalidRequest { message } => write!(f, "Invalid request: {}", message),
121 Self::ExecutionFailed { message } => write!(f, "Execution failed: {}", message),
122 Self::Unavailable { message } => write!(f, "Backend unavailable: {}", message),
123 Self::BudgetExceeded { resource, limit } => {
124 write!(f, "Budget exceeded: {} (limit: {})", resource, limit)
125 }
126 Self::ContractFailed { contract, message } => {
127 write!(f, "Contract '{}' failed: {}", contract, message)
128 }
129 Self::UnsupportedCapability { capability } => {
130 write!(f, "Unsupported capability: {:?}", capability)
131 }
132 Self::AdapterError { message } => write!(f, "Adapter error: {}", message),
133 Self::RecallError { message } => write!(f, "Recall error: {}", message),
134 Self::Timeout {
135 deadline_ms,
136 elapsed_ms,
137 } => {
138 write!(
139 f,
140 "Operation timed out: elapsed {}ms, deadline {}ms",
141 elapsed_ms, deadline_ms
142 )
143 }
144 Self::CircuitOpen {
145 backend,
146 retry_after_ms,
147 } => {
148 if let Some(retry_after) = retry_after_ms {
149 write!(
150 f,
151 "Circuit breaker open for '{}', retry after {}ms",
152 backend, retry_after
153 )
154 } else {
155 write!(f, "Circuit breaker open for '{}'", backend)
156 }
157 }
158 Self::Retried {
159 message,
160 attempts,
161 was_transient,
162 } => {
163 write!(
164 f,
165 "Failed after {} attempts (transient: {}): {}",
166 attempts, was_transient, message
167 )
168 }
169 Self::Other { message } => write!(f, "{}", message),
170 }
171 }
172}
173
174impl std::error::Error for BackendError {}
175
176impl BackendError {
177 #[must_use]
191 pub fn is_retryable(&self) -> bool {
192 match self {
193 Self::Timeout { .. } => true,
194 Self::Unavailable { .. } => true,
195 Self::ExecutionFailed { message } => {
196 let msg_lower = message.to_lowercase();
198 msg_lower.contains("timeout")
199 || msg_lower.contains("rate limit")
200 || msg_lower.contains("429")
201 || msg_lower.contains("503")
202 || msg_lower.contains("502")
203 || msg_lower.contains("504")
204 || msg_lower.contains("connection")
205 || msg_lower.contains("network")
206 }
207 Self::RecallError { message } => {
208 let msg_lower = message.to_lowercase();
210 msg_lower.contains("timeout") || msg_lower.contains("unavailable")
211 }
212 Self::InvalidRequest { .. } => false,
214 Self::BudgetExceeded { .. } => false,
215 Self::ContractFailed { .. } => false,
216 Self::UnsupportedCapability { .. } => false,
217 Self::AdapterError { .. } => false,
218 Self::CircuitOpen { .. } => false, Self::Retried { .. } => false, Self::Other { .. } => false,
221 }
222 }
223
224 #[must_use]
228 pub fn is_overload(&self) -> bool {
229 match self {
230 Self::Unavailable { .. } => true,
231 Self::Timeout { .. } => true,
232 Self::ExecutionFailed { message } => {
233 let msg_lower = message.to_lowercase();
234 msg_lower.contains("rate limit")
235 || msg_lower.contains("429")
236 || msg_lower.contains("503")
237 || msg_lower.contains("overloaded")
238 }
239 _ => false,
240 }
241 }
242}
243
244pub type BackendResult<T> = Result<T, BackendError>;
246
247#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
256pub enum BackendCapability {
257 Replay,
259 Adapters,
261 Recall,
263 StepContracts,
265 FrontierReasoning,
267 FastIteration,
269 Offline,
271 Streaming,
273 Vision,
275 ToolUse,
277}
278
279#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
285pub enum BackoffStrategy {
286 Fixed,
288 Linear,
290 Exponential,
292}
293
294impl Default for BackoffStrategy {
295 fn default() -> Self {
296 Self::Exponential
297 }
298}
299
300#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320pub struct RetryPolicy {
321 pub max_attempts: usize,
323 pub initial_delay_ms: u64,
325 pub max_delay_ms: u64,
327 pub backoff: BackoffStrategy,
329 pub jitter_percent: u8,
331}
332
333impl Default for RetryPolicy {
334 fn default() -> Self {
335 Self {
336 max_attempts: 3,
337 initial_delay_ms: 100,
338 max_delay_ms: 10_000,
339 backoff: BackoffStrategy::Exponential,
340 jitter_percent: 20,
341 }
342 }
343}
344
345impl RetryPolicy {
346 #[must_use]
348 pub fn no_retry() -> Self {
349 Self {
350 max_attempts: 1,
351 ..Default::default()
352 }
353 }
354
355 #[must_use]
357 pub fn aggressive() -> Self {
358 Self {
359 max_attempts: 5,
360 initial_delay_ms: 50,
361 max_delay_ms: 30_000,
362 backoff: BackoffStrategy::Exponential,
363 jitter_percent: 25,
364 }
365 }
366
367 #[must_use]
371 pub fn delay_for_attempt(&self, attempt: usize) -> u64 {
372 if attempt == 0 {
373 return 0;
374 }
375 let attempt = attempt.saturating_sub(1); let delay = match self.backoff {
378 BackoffStrategy::Fixed => self.initial_delay_ms,
379 BackoffStrategy::Linear => self.initial_delay_ms.saturating_mul(attempt as u64 + 1),
380 BackoffStrategy::Exponential => {
381 self.initial_delay_ms.saturating_mul(1u64 << attempt.min(10))
382 }
383 };
384
385 delay.min(self.max_delay_ms)
386 }
387
388 #[must_use]
390 pub fn should_retry(&self, attempt: usize) -> bool {
391 attempt < self.max_attempts
392 }
393}
394
395#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
423pub struct CircuitBreakerConfig {
424 pub failure_threshold: usize,
426 pub success_threshold: usize,
428 pub timeout_ms: u64,
430 pub half_open_max_requests: usize,
432}
433
434impl Default for CircuitBreakerConfig {
435 fn default() -> Self {
436 Self {
437 failure_threshold: 5,
438 success_threshold: 2,
439 timeout_ms: 30_000,
440 half_open_max_requests: 3,
441 }
442 }
443}
444
445impl CircuitBreakerConfig {
446 #[must_use]
448 pub fn sensitive() -> Self {
449 Self {
450 failure_threshold: 3,
451 success_threshold: 1,
452 timeout_ms: 15_000,
453 half_open_max_requests: 1,
454 }
455 }
456
457 #[must_use]
459 pub fn tolerant() -> Self {
460 Self {
461 failure_threshold: 10,
462 success_threshold: 3,
463 timeout_ms: 60_000,
464 half_open_max_requests: 5,
465 }
466 }
467
468 #[must_use]
470 pub fn disabled() -> Self {
471 Self {
472 failure_threshold: usize::MAX,
473 success_threshold: 1,
474 timeout_ms: 0,
475 half_open_max_requests: usize::MAX,
476 }
477 }
478}
479
480#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
485pub enum CircuitState {
486 #[default]
488 Closed,
489 Open,
491 HalfOpen,
493}
494
495#[derive(Debug, Clone, Serialize, Deserialize)]
503pub struct BackendRequest {
504 pub intent_id: String,
506 pub truth_ids: Vec<String>,
508 pub prompt_version: String,
510 pub state_injection_hash: String,
512 pub prompt: BackendPrompt,
514 pub contracts: Vec<ContractSpec>,
516 pub budgets: BackendBudgets,
518 pub recall_policy: Option<BackendRecallPolicy>,
520 pub adapter_policy: Option<BackendAdapterPolicy>,
522 #[serde(default)]
524 pub retry_policy: Option<RetryPolicy>,
525}
526
527#[derive(Debug, Clone, Serialize, Deserialize)]
529pub enum BackendPrompt {
530 Text(String),
532 Messages(Vec<Message>),
534}
535
536#[derive(Debug, Clone, Serialize, Deserialize)]
538pub struct Message {
539 pub role: MessageRole,
540 pub content: String,
541}
542
543impl Message {
544 pub fn system(content: impl Into<String>) -> Self {
546 Self {
547 role: MessageRole::System,
548 content: content.into(),
549 }
550 }
551
552 pub fn user(content: impl Into<String>) -> Self {
554 Self {
555 role: MessageRole::User,
556 content: content.into(),
557 }
558 }
559
560 pub fn assistant(content: impl Into<String>) -> Self {
562 Self {
563 role: MessageRole::Assistant,
564 content: content.into(),
565 }
566 }
567}
568
569#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
571pub enum MessageRole {
572 System,
573 User,
574 Assistant,
575}
576
577#[derive(Debug, Clone, Serialize, Deserialize)]
579pub struct ContractSpec {
580 pub name: String,
582 pub schema: Option<serde_json::Value>,
584 pub required: bool,
586}
587
588#[derive(Debug, Clone, Serialize, Deserialize)]
590pub struct BackendBudgets {
591 pub max_tokens: usize,
593 pub max_iterations: usize,
595 pub latency_ceiling_ms: u64,
597 pub cost_ceiling_microdollars: u64,
599}
600
601impl Default for BackendBudgets {
602 fn default() -> Self {
603 Self {
604 max_tokens: 1024,
605 max_iterations: 1,
606 latency_ceiling_ms: 0,
607 cost_ceiling_microdollars: 0,
608 }
609 }
610}
611
612#[derive(Debug, Clone, Serialize, Deserialize)]
614pub struct BackendRecallPolicy {
615 pub enabled: bool,
616 pub max_candidates: usize,
617 pub min_score: f32,
618 pub corpus_filter: Option<String>,
619}
620
621impl Default for BackendRecallPolicy {
622 fn default() -> Self {
623 Self {
624 enabled: false,
625 max_candidates: 5,
626 min_score: 0.5,
627 corpus_filter: None,
628 }
629 }
630}
631
632#[derive(Debug, Clone, Serialize, Deserialize)]
634pub struct BackendAdapterPolicy {
635 pub adapter_id: Option<String>,
637 pub required: bool,
639}
640
641impl Default for BackendAdapterPolicy {
642 fn default() -> Self {
643 Self {
644 adapter_id: None,
645 required: false,
646 }
647 }
648}
649
650#[derive(Debug, Clone, Serialize, Deserialize)]
658pub struct BackendResponse {
659 pub proposals: Vec<ProposedContent>,
661 pub contract_report: ContractReport,
663 pub trace_link: TraceLink,
665 pub usage: BackendUsage,
667}
668
669#[derive(Debug, Clone, Serialize, Deserialize)]
671pub struct ContractReport {
672 pub results: Vec<BackendContractResult>,
674 pub all_passed: bool,
676}
677
678impl ContractReport {
679 pub fn empty_pass() -> Self {
681 Self {
682 results: vec![],
683 all_passed: true,
684 }
685 }
686
687 pub fn from_results(results: Vec<BackendContractResult>) -> Self {
689 let all_passed = results.iter().all(|r| r.passed);
690 Self { results, all_passed }
691 }
692}
693
694#[derive(Debug, Clone, Serialize, Deserialize)]
696pub struct BackendContractResult {
697 pub name: String,
698 pub passed: bool,
699 pub diagnostics: Option<String>,
700}
701
702impl BackendContractResult {
703 pub fn pass(name: impl Into<String>) -> Self {
705 Self {
706 name: name.into(),
707 passed: true,
708 diagnostics: None,
709 }
710 }
711
712 pub fn fail(name: impl Into<String>, diagnostics: impl Into<String>) -> Self {
714 Self {
715 name: name.into(),
716 passed: false,
717 diagnostics: Some(diagnostics.into()),
718 }
719 }
720}
721
722#[derive(Debug, Clone, Serialize, Deserialize, Default)]
724pub struct BackendUsage {
725 pub input_tokens: usize,
726 pub output_tokens: usize,
727 pub total_tokens: usize,
728 pub latency_ms: u64,
729 pub cost_microdollars: Option<u64>,
730}
731
732#[deprecated(
763 since = "0.2.0",
764 note = "Use converge_core::traits::LlmBackend (GAT async) instead. See BOUNDARY.md for migration."
765)]
766pub trait LlmBackend: Send + Sync {
767 fn name(&self) -> &str;
769
770 fn supports_replay(&self) -> bool;
775
776 fn execute(&self, request: &BackendRequest) -> BackendResult<BackendResponse>;
784
785 fn supports_capability(&self, capability: BackendCapability) -> bool;
789
790 fn capabilities(&self) -> Vec<BackendCapability> {
794 let all_caps = [
795 BackendCapability::Replay,
796 BackendCapability::Adapters,
797 BackendCapability::Recall,
798 BackendCapability::StepContracts,
799 BackendCapability::FrontierReasoning,
800 BackendCapability::FastIteration,
801 BackendCapability::Offline,
802 BackendCapability::Streaming,
803 BackendCapability::Vision,
804 BackendCapability::ToolUse,
805 ];
806 all_caps
807 .iter()
808 .filter(|cap| self.supports_capability(**cap))
809 .copied()
810 .collect()
811 }
812}
813
814#[cfg(test)]
815mod tests {
816 use super::*;
817
818 #[test]
819 fn test_backend_budgets_default() {
820 let budgets = BackendBudgets::default();
821 assert_eq!(budgets.max_tokens, 1024);
822 assert_eq!(budgets.max_iterations, 1);
823 assert_eq!(budgets.latency_ceiling_ms, 0);
824 assert_eq!(budgets.cost_ceiling_microdollars, 0);
825 }
826
827 #[test]
828 fn test_message_constructors() {
829 let system = Message::system("You are a helpful assistant");
830 assert_eq!(system.role, MessageRole::System);
831 assert_eq!(system.content, "You are a helpful assistant");
832
833 let user = Message::user("Hello");
834 assert_eq!(user.role, MessageRole::User);
835
836 let assistant = Message::assistant("Hi there!");
837 assert_eq!(assistant.role, MessageRole::Assistant);
838 }
839
840 #[test]
841 fn test_contract_report_from_results() {
842 let results = vec![
843 BackendContractResult::pass("contract1"),
844 BackendContractResult::pass("contract2"),
845 ];
846 let report = ContractReport::from_results(results);
847 assert!(report.all_passed);
848
849 let mixed = vec![
850 BackendContractResult::pass("contract1"),
851 BackendContractResult::fail("contract2", "missing field"),
852 ];
853 let report = ContractReport::from_results(mixed);
854 assert!(!report.all_passed);
855 }
856
857 #[test]
858 fn test_backend_error_display() {
859 let err = BackendError::BudgetExceeded {
860 resource: "tokens".to_string(),
861 limit: "1024".to_string(),
862 };
863 assert!(err.to_string().contains("tokens"));
864 assert!(err.to_string().contains("1024"));
865 }
866
867 #[test]
868 fn test_capability_serialization_stable() {
869 assert_eq!(
870 serde_json::to_string(&BackendCapability::Replay).unwrap(),
871 "\"Replay\""
872 );
873 assert_eq!(
874 serde_json::to_string(&BackendCapability::FrontierReasoning).unwrap(),
875 "\"FrontierReasoning\""
876 );
877 }
878
879 #[test]
880 fn test_message_role_serialization_stable() {
881 assert_eq!(
882 serde_json::to_string(&MessageRole::System).unwrap(),
883 "\"System\""
884 );
885 assert_eq!(
886 serde_json::to_string(&MessageRole::User).unwrap(),
887 "\"User\""
888 );
889 assert_eq!(
890 serde_json::to_string(&MessageRole::Assistant).unwrap(),
891 "\"Assistant\""
892 );
893 }
894
895 #[test]
900 fn test_retry_policy_default() {
901 let policy = RetryPolicy::default();
902 assert_eq!(policy.max_attempts, 3);
903 assert_eq!(policy.initial_delay_ms, 100);
904 assert_eq!(policy.backoff, BackoffStrategy::Exponential);
905 }
906
907 #[test]
908 fn test_retry_policy_no_retry() {
909 let policy = RetryPolicy::no_retry();
910 assert_eq!(policy.max_attempts, 1);
911 assert!(!policy.should_retry(1));
912 }
913
914 #[test]
915 fn test_retry_policy_delay_exponential() {
916 let policy = RetryPolicy {
917 max_attempts: 5,
918 initial_delay_ms: 100,
919 max_delay_ms: 10_000,
920 backoff: BackoffStrategy::Exponential,
921 jitter_percent: 0,
922 };
923
924 assert_eq!(policy.delay_for_attempt(1), 100); assert_eq!(policy.delay_for_attempt(2), 200); assert_eq!(policy.delay_for_attempt(3), 400); assert_eq!(policy.delay_for_attempt(4), 800); }
929
930 #[test]
931 fn test_retry_policy_delay_linear() {
932 let policy = RetryPolicy {
933 max_attempts: 5,
934 initial_delay_ms: 100,
935 max_delay_ms: 10_000,
936 backoff: BackoffStrategy::Linear,
937 jitter_percent: 0,
938 };
939
940 assert_eq!(policy.delay_for_attempt(1), 100); assert_eq!(policy.delay_for_attempt(2), 200); assert_eq!(policy.delay_for_attempt(3), 300); }
944
945 #[test]
946 fn test_retry_policy_delay_fixed() {
947 let policy = RetryPolicy {
948 max_attempts: 5,
949 initial_delay_ms: 100,
950 max_delay_ms: 10_000,
951 backoff: BackoffStrategy::Fixed,
952 jitter_percent: 0,
953 };
954
955 assert_eq!(policy.delay_for_attempt(1), 100);
956 assert_eq!(policy.delay_for_attempt(2), 100);
957 assert_eq!(policy.delay_for_attempt(3), 100);
958 }
959
960 #[test]
961 fn test_retry_policy_max_delay_cap() {
962 let policy = RetryPolicy {
963 max_attempts: 20,
964 initial_delay_ms: 1000,
965 max_delay_ms: 5000,
966 backoff: BackoffStrategy::Exponential,
967 jitter_percent: 0,
968 };
969
970 assert_eq!(policy.delay_for_attempt(10), 5000);
972 }
973
974 #[test]
975 fn test_retry_policy_should_retry() {
976 let policy = RetryPolicy {
977 max_attempts: 3,
978 ..Default::default()
979 };
980
981 assert!(policy.should_retry(1));
982 assert!(policy.should_retry(2));
983 assert!(!policy.should_retry(3));
984 assert!(!policy.should_retry(4));
985 }
986
987 #[test]
992 fn test_circuit_breaker_config_default() {
993 let config = CircuitBreakerConfig::default();
994 assert_eq!(config.failure_threshold, 5);
995 assert_eq!(config.success_threshold, 2);
996 assert_eq!(config.timeout_ms, 30_000);
997 }
998
999 #[test]
1000 fn test_circuit_breaker_config_sensitive() {
1001 let config = CircuitBreakerConfig::sensitive();
1002 assert_eq!(config.failure_threshold, 3);
1003 assert!(config.failure_threshold < CircuitBreakerConfig::default().failure_threshold);
1004 }
1005
1006 #[test]
1007 fn test_circuit_breaker_config_tolerant() {
1008 let config = CircuitBreakerConfig::tolerant();
1009 assert_eq!(config.failure_threshold, 10);
1010 assert!(config.failure_threshold > CircuitBreakerConfig::default().failure_threshold);
1011 }
1012
1013 #[test]
1014 fn test_circuit_state_default() {
1015 let state = CircuitState::default();
1016 assert_eq!(state, CircuitState::Closed);
1017 }
1018
1019 #[test]
1024 fn test_timeout_is_retryable() {
1025 let err = BackendError::Timeout {
1026 deadline_ms: 5000,
1027 elapsed_ms: 5001,
1028 };
1029 assert!(err.is_retryable());
1030 assert!(err.is_overload());
1031 }
1032
1033 #[test]
1034 fn test_unavailable_is_retryable() {
1035 let err = BackendError::Unavailable {
1036 message: "Service temporarily unavailable".to_string(),
1037 };
1038 assert!(err.is_retryable());
1039 assert!(err.is_overload());
1040 }
1041
1042 #[test]
1043 fn test_rate_limit_is_retryable() {
1044 let err = BackendError::ExecutionFailed {
1045 message: "Rate limit exceeded (429)".to_string(),
1046 };
1047 assert!(err.is_retryable());
1048 assert!(err.is_overload());
1049 }
1050
1051 #[test]
1052 fn test_invalid_request_not_retryable() {
1053 let err = BackendError::InvalidRequest {
1054 message: "Missing required field".to_string(),
1055 };
1056 assert!(!err.is_retryable());
1057 assert!(!err.is_overload());
1058 }
1059
1060 #[test]
1061 fn test_budget_exceeded_not_retryable() {
1062 let err = BackendError::BudgetExceeded {
1063 resource: "tokens".to_string(),
1064 limit: "1024".to_string(),
1065 };
1066 assert!(!err.is_retryable());
1067 assert!(!err.is_overload());
1068 }
1069
1070 #[test]
1071 fn test_circuit_open_not_retryable() {
1072 let err = BackendError::CircuitOpen {
1073 backend: "anthropic".to_string(),
1074 retry_after_ms: Some(30_000),
1075 };
1076 assert!(!err.is_retryable());
1077 assert!(!err.is_overload());
1078 }
1079
1080 #[test]
1081 fn test_timeout_error_display() {
1082 let err = BackendError::Timeout {
1083 deadline_ms: 5000,
1084 elapsed_ms: 6000,
1085 };
1086 let msg = err.to_string();
1087 assert!(msg.contains("6000"));
1088 assert!(msg.contains("5000"));
1089 }
1090
1091 #[test]
1092 fn test_circuit_open_error_display() {
1093 let err = BackendError::CircuitOpen {
1094 backend: "test-backend".to_string(),
1095 retry_after_ms: Some(30_000),
1096 };
1097 let msg = err.to_string();
1098 assert!(msg.contains("test-backend"));
1099 assert!(msg.contains("30000"));
1100 }
1101
1102 #[test]
1103 fn test_retried_error_display() {
1104 let err = BackendError::Retried {
1105 message: "Final error".to_string(),
1106 attempts: 3,
1107 was_transient: true,
1108 };
1109 let msg = err.to_string();
1110 assert!(msg.contains("3 attempts"));
1111 assert!(msg.contains("transient: true"));
1112 }
1113
1114 #[test]
1119 fn test_retry_policy_serialization_stable() {
1120 let policy = RetryPolicy::default();
1121 let json = serde_json::to_string(&policy).unwrap();
1122 assert!(json.contains("\"max_attempts\":3"));
1123 assert!(json.contains("\"Exponential\""));
1124
1125 let parsed: RetryPolicy = serde_json::from_str(&json).unwrap();
1127 assert_eq!(parsed, policy);
1128 }
1129
1130 #[test]
1131 fn test_circuit_breaker_config_serialization_stable() {
1132 let config = CircuitBreakerConfig::default();
1133 let json = serde_json::to_string(&config).unwrap();
1134 assert!(json.contains("\"failure_threshold\":5"));
1135
1136 let parsed: CircuitBreakerConfig = serde_json::from_str(&json).unwrap();
1138 assert_eq!(parsed, config);
1139 }
1140}