1use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16pub enum AgentRole {
17 Developer,
19 Reviewer,
21 Commit,
23 Analysis,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
32pub enum AgentDrain {
33 Planning,
34 Development,
35 Review,
36 Fix,
37 Commit,
38 Analysis,
39}
40
41impl AgentDrain {
42 #[must_use]
44 pub const fn role(self) -> AgentRole {
45 match self {
46 Self::Planning | Self::Development => AgentRole::Developer,
47 Self::Review | Self::Fix => AgentRole::Reviewer,
48 Self::Commit => AgentRole::Commit,
49 Self::Analysis => AgentRole::Analysis,
50 }
51 }
52
53 #[must_use]
55 pub const fn as_str(self) -> &'static str {
56 match self {
57 Self::Planning => "planning",
58 Self::Development => "development",
59 Self::Review => "review",
60 Self::Fix => "fix",
61 Self::Commit => "commit",
62 Self::Analysis => "analysis",
63 }
64 }
65
66 #[must_use]
68 pub fn from_name(name: &str) -> Option<Self> {
69 match name {
70 "planning" => Some(Self::Planning),
71 "development" => Some(Self::Development),
72 "review" => Some(Self::Review),
73 "fix" => Some(Self::Fix),
74 "commit" => Some(Self::Commit),
75 "analysis" => Some(Self::Analysis),
76 _ => None,
77 }
78 }
79
80 #[must_use]
82 pub const fn all() -> [Self; 6] {
83 [
84 Self::Planning,
85 Self::Development,
86 Self::Review,
87 Self::Fix,
88 Self::Commit,
89 Self::Analysis,
90 ]
91 }
92}
93
94impl From<AgentRole> for AgentDrain {
95 fn from(value: AgentRole) -> Self {
96 match value {
97 AgentRole::Developer => Self::Development,
98 AgentRole::Reviewer => Self::Review,
99 AgentRole::Commit => Self::Commit,
100 AgentRole::Analysis => Self::Analysis,
101 }
102 }
103}
104
105impl std::fmt::Display for AgentDrain {
106 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107 write!(f, "{}", self.as_str())
108 }
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
113pub enum DrainMode {
114 #[default]
115 Normal,
116 Continuation,
117 SameAgentRetry,
118 XsdRetry,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct ResolvedDrainBinding {
124 pub chain_name: String,
125 pub agents: Vec<String>,
126}
127
128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
130pub struct ResolvedDrainConfig {
131 pub bindings: HashMap<AgentDrain, ResolvedDrainBinding>,
132 pub provider_fallback: HashMap<String, Vec<String>>,
133 pub max_retries: u32,
134 pub retry_delay_ms: u64,
135 pub backoff_multiplier: f64,
136 pub max_backoff_ms: u64,
137 pub max_cycles: u32,
138}
139
140impl ResolvedDrainConfig {
141 #[must_use]
143 pub fn binding(&self, drain: AgentDrain) -> Option<&ResolvedDrainBinding> {
144 self.bindings.get(&drain)
145 }
146
147 #[must_use]
149 pub fn from_legacy(fallback: &FallbackConfig) -> Self {
150 let bindings = AgentDrain::all()
151 .into_iter()
152 .map(|drain| {
153 let role = drain.role();
154 let chain_name = fallback.effective_chain_name_for_role(role).to_string();
155 (
156 drain,
157 ResolvedDrainBinding {
158 chain_name,
159 agents: fallback.get_fallbacks(role).to_vec(),
160 },
161 )
162 })
163 .collect();
164
165 Self {
166 bindings,
167 provider_fallback: fallback.provider_fallback.clone(),
168 max_retries: fallback.max_retries,
169 retry_delay_ms: fallback.retry_delay_ms,
170 backoff_multiplier: fallback.backoff_multiplier,
171 max_backoff_ms: fallback.max_backoff_ms,
172 max_cycles: fallback.max_cycles,
173 }
174 }
175
176 #[must_use]
181 pub fn to_legacy_fallback(&self) -> FallbackConfig {
182 FallbackConfig {
183 developer: self
184 .binding(AgentDrain::Development)
185 .map_or_else(Vec::new, |binding| binding.agents.clone()),
186 reviewer: self
187 .binding(AgentDrain::Review)
188 .map_or_else(Vec::new, |binding| binding.agents.clone()),
189 commit: self
190 .binding(AgentDrain::Commit)
191 .map_or_else(Vec::new, |binding| binding.agents.clone()),
192 analysis: self
193 .binding(AgentDrain::Analysis)
194 .map_or_else(Vec::new, |binding| binding.agents.clone()),
195 provider_fallback: self.provider_fallback.clone(),
196 max_retries: self.max_retries,
197 retry_delay_ms: self.retry_delay_ms,
198 backoff_multiplier: self.backoff_multiplier,
199 max_backoff_ms: self.max_backoff_ms,
200 max_cycles: self.max_cycles,
201 legacy_role_keys_present: false,
202 }
203 }
204}
205
206impl std::fmt::Display for AgentRole {
207 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
208 match self {
209 Self::Developer => write!(f, "developer"),
210 Self::Reviewer => write!(f, "reviewer"),
211 Self::Commit => write!(f, "commit"),
212 Self::Analysis => write!(f, "analysis"),
213 }
214 }
215}
216
217#[derive(Debug, Clone, Serialize)]
248pub struct FallbackConfig {
249 #[serde(default)]
251 pub developer: Vec<String>,
252 #[serde(default)]
254 pub reviewer: Vec<String>,
255 #[serde(default)]
257 pub commit: Vec<String>,
258 #[serde(default)]
262 pub analysis: Vec<String>,
263 #[serde(default)]
266 pub provider_fallback: HashMap<String, Vec<String>>,
267 #[serde(default = "default_max_retries")]
269 pub max_retries: u32,
270 #[serde(default = "default_retry_delay_ms")]
272 pub retry_delay_ms: u64,
273 #[serde(default = "default_backoff_multiplier")]
275 pub backoff_multiplier: f64,
276 #[serde(default = "default_max_backoff_ms")]
278 pub max_backoff_ms: u64,
279 #[serde(default = "default_max_cycles")]
281 pub max_cycles: u32,
282 #[serde(skip)]
283 pub(crate) legacy_role_keys_present: bool,
284}
285
286impl<'de> Deserialize<'de> for FallbackConfig {
287 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
288 where
289 D: serde::Deserializer<'de>,
290 {
291 #[derive(Deserialize)]
292 struct FallbackConfigSerde {
293 #[serde(default)]
294 developer: Option<Vec<String>>,
295 #[serde(default)]
296 reviewer: Option<Vec<String>>,
297 #[serde(default)]
298 commit: Option<Vec<String>>,
299 #[serde(default)]
300 analysis: Option<Vec<String>>,
301 #[serde(default)]
302 provider_fallback: HashMap<String, Vec<String>>,
303 #[serde(default = "default_max_retries")]
304 max_retries: u32,
305 #[serde(default = "default_retry_delay_ms")]
306 retry_delay_ms: u64,
307 #[serde(default = "default_backoff_multiplier")]
308 backoff_multiplier: f64,
309 #[serde(default = "default_max_backoff_ms")]
310 max_backoff_ms: u64,
311 #[serde(default = "default_max_cycles")]
312 max_cycles: u32,
313 }
314
315 let raw = FallbackConfigSerde::deserialize(deserializer)?;
316 let legacy_role_keys_present = raw.developer.is_some()
317 || raw.reviewer.is_some()
318 || raw.commit.is_some()
319 || raw.analysis.is_some();
320
321 Ok(Self {
322 developer: raw.developer.unwrap_or_default(),
323 reviewer: raw.reviewer.unwrap_or_default(),
324 commit: raw.commit.unwrap_or_default(),
325 analysis: raw.analysis.unwrap_or_default(),
326 provider_fallback: raw.provider_fallback,
327 max_retries: raw.max_retries,
328 retry_delay_ms: raw.retry_delay_ms,
329 backoff_multiplier: raw.backoff_multiplier,
330 max_backoff_ms: raw.max_backoff_ms,
331 max_cycles: raw.max_cycles,
332 legacy_role_keys_present,
333 })
334 }
335}
336
337const fn default_max_retries() -> u32 {
338 3
339}
340
341const fn default_retry_delay_ms() -> u64 {
342 1000
343}
344
345const fn default_backoff_multiplier() -> f64 {
346 2.0
347}
348
349const fn default_max_backoff_ms() -> u64 {
350 60000 }
352
353const fn default_max_cycles() -> u32 {
354 3
355}
356
357const IEEE_754_EXP_BIAS: i32 = 1023;
359const IEEE_754_EXP_MASK: u64 = 0x7FF;
360const IEEE_754_MANTISSA_MASK: u64 = 0x000F_FFFF_FFFF_FFFF;
361const IEEE_754_IMPLICIT_ONE: u64 = 1u64 << 52;
362
363#[expect(
369 clippy::arithmetic_side_effects,
370 reason = "IEEE 754 bit manipulation with bounded values"
371)]
372fn f64_to_u64_via_bits(value: f64) -> u64 {
373 if !value.is_finite() || value < 0.0 {
375 return 0;
376 }
377
378 let bits = value.to_bits();
380
381 let exp_biased = ((bits >> 52) & IEEE_754_EXP_MASK) as i32;
386 let mantissa = bits & IEEE_754_MANTISSA_MASK;
387
388 if exp_biased == 0 {
390 return 0;
393 }
394
395 let exp = exp_biased - IEEE_754_EXP_BIAS;
397
398 if exp < 0 {
401 return 0;
402 }
403
404 let full_mantissa = mantissa | IEEE_754_IMPLICIT_ONE;
407
408 let shift = 52i32 - exp;
411
412 if shift <= 0 {
413 u64::MAX
416 } else if shift < 64 {
417 full_mantissa >> shift
418 } else {
419 0
420 }
421}
422
423impl Default for FallbackConfig {
424 fn default() -> Self {
425 Self {
426 developer: Vec::new(),
427 reviewer: Vec::new(),
428 commit: Vec::new(),
429 analysis: Vec::new(),
430 provider_fallback: HashMap::new(),
431 max_retries: default_max_retries(),
432 retry_delay_ms: default_retry_delay_ms(),
433 backoff_multiplier: default_backoff_multiplier(),
434 max_backoff_ms: default_max_backoff_ms(),
435 max_cycles: default_max_cycles(),
436 legacy_role_keys_present: false,
437 }
438 }
439}
440
441impl FallbackConfig {
442 #[must_use]
444 pub fn has_role_bindings(&self) -> bool {
445 [
446 self.developer.as_slice(),
447 self.reviewer.as_slice(),
448 self.commit.as_slice(),
449 self.analysis.as_slice(),
450 ]
451 .into_iter()
452 .any(|chain| !chain.is_empty())
453 }
454
455 #[must_use]
457 pub const fn has_legacy_role_key_presence(&self) -> bool {
458 self.legacy_role_keys_present
459 }
460
461 #[must_use]
463 pub fn uses_legacy_role_schema(&self) -> bool {
464 self.legacy_role_keys_present || self.has_role_bindings()
465 }
466
467 const fn effective_chain_name_for_role(&self, role: AgentRole) -> &'static str {
468 match role {
469 AgentRole::Developer => "developer",
470 AgentRole::Reviewer => "reviewer",
471 AgentRole::Commit => {
472 if self.commit.is_empty() {
473 "reviewer"
474 } else {
475 "commit"
476 }
477 }
478 AgentRole::Analysis => {
479 if self.analysis.is_empty() {
480 "developer"
481 } else {
482 "analysis"
483 }
484 }
485 }
486 }
487
488 #[must_use]
494 pub fn calculate_backoff(&self, cycle: u32) -> u64 {
495 let multiplier_hundredths = self.get_multiplier_hundredths();
498 let base_hundredths = self.retry_delay_ms.saturating_mul(100);
499
500 let delay_hundredths = (0..cycle).fold(base_hundredths, |acc, _| {
503 acc.saturating_mul(multiplier_hundredths)
504 .saturating_div(100)
505 });
506
507 delay_hundredths.div_euclid(100).min(self.max_backoff_ms)
509 }
510
511 fn get_multiplier_hundredths(&self) -> u64 {
516 const EPSILON: f64 = 0.0001;
517
518 let m = self.backoff_multiplier;
521 if (m - 1.0).abs() < EPSILON {
522 return 100;
523 } else if (m - 1.5).abs() < EPSILON {
524 return 150;
525 } else if (m - 2.0).abs() < EPSILON {
526 return 200;
527 } else if (m - 2.5).abs() < EPSILON {
528 return 250;
529 } else if (m - 3.0).abs() < EPSILON {
530 return 300;
531 } else if (m - 4.0).abs() < EPSILON {
532 return 400;
533 } else if (m - 5.0).abs() < EPSILON {
534 return 500;
535 } else if (m - 10.0).abs() < EPSILON {
536 return 1000;
537 }
538
539 let clamped = m.clamp(0.0, 1000.0);
543 let multiplied = clamped * 100.0;
544 let rounded = multiplied.round();
545
546 f64_to_u64_via_bits(rounded)
548 }
549
550 #[must_use]
552 pub fn get_fallbacks(&self, role: AgentRole) -> &[String] {
553 match role {
554 AgentRole::Developer => &self.developer,
555 AgentRole::Reviewer => &self.reviewer,
556 AgentRole::Commit => self.get_effective_commit_fallbacks(),
557 AgentRole::Analysis => self.get_effective_analysis_fallbacks(),
558 }
559 }
560
561 fn get_effective_analysis_fallbacks(&self) -> &[String] {
565 if self.analysis.is_empty() {
566 &self.developer
567 } else {
568 &self.analysis
569 }
570 }
571
572 fn get_effective_commit_fallbacks(&self) -> &[String] {
578 if self.commit.is_empty() {
579 &self.reviewer
580 } else {
581 &self.commit
582 }
583 }
584
585 #[must_use]
587 pub fn has_fallbacks(&self, role: AgentRole) -> bool {
588 !self.get_fallbacks(role).is_empty()
589 }
590
591 pub fn get_provider_fallbacks(&self, agent_name: &str) -> &[String] {
596 self.provider_fallback
597 .get(agent_name)
598 .map_or(&[], std::vec::Vec::as_slice)
599 }
600
601 #[must_use]
603 pub fn has_provider_fallbacks(&self, agent_name: &str) -> bool {
604 self.provider_fallback
605 .get(agent_name)
606 .is_some_and(|v| !v.is_empty())
607 }
608
609 #[must_use]
611 pub fn resolve_drains(&self) -> ResolvedDrainConfig {
612 ResolvedDrainConfig::from_legacy(self)
613 }
614}
615
616#[cfg(test)]
617mod tests {
618 use super::*;
619
620 #[test]
621 fn test_agent_role_display() {
622 assert_eq!(format!("{}", AgentRole::Developer), "developer");
623 assert_eq!(format!("{}", AgentRole::Reviewer), "reviewer");
624 assert_eq!(format!("{}", AgentRole::Commit), "commit");
625 assert_eq!(format!("{}", AgentRole::Analysis), "analysis");
626 }
627
628 #[test]
629 fn test_agent_drain_role_mapping() {
630 assert_eq!(AgentDrain::Planning.role(), AgentRole::Developer);
631 assert_eq!(AgentDrain::Development.role(), AgentRole::Developer);
632 assert_eq!(AgentDrain::Review.role(), AgentRole::Reviewer);
633 assert_eq!(AgentDrain::Fix.role(), AgentRole::Reviewer);
634 assert_eq!(AgentDrain::Commit.role(), AgentRole::Commit);
635 assert_eq!(AgentDrain::Analysis.role(), AgentRole::Analysis);
636 }
637
638 #[test]
639 fn test_fallback_config_defaults() {
640 let config = FallbackConfig::default();
641 assert!(config.developer.is_empty());
642 assert!(config.reviewer.is_empty());
643 assert!(config.commit.is_empty());
644 assert!(config.analysis.is_empty());
645 assert_eq!(config.max_retries, 3);
646 assert_eq!(config.retry_delay_ms, 1000);
647 assert!((config.backoff_multiplier - 2.0).abs() < f64::EPSILON);
649 assert_eq!(config.max_backoff_ms, 60000);
650 assert_eq!(config.max_cycles, 3);
651 }
652
653 #[test]
654 fn test_fallback_config_calculate_backoff() {
655 let config = FallbackConfig {
656 retry_delay_ms: 1000,
657 backoff_multiplier: 2.0,
658 max_backoff_ms: 60000,
659 ..Default::default()
660 };
661
662 assert_eq!(config.calculate_backoff(0), 1000);
663 assert_eq!(config.calculate_backoff(1), 2000);
664 assert_eq!(config.calculate_backoff(2), 4000);
665 assert_eq!(config.calculate_backoff(3), 8000);
666
667 assert_eq!(config.calculate_backoff(10), 60000);
669 }
670
671 #[test]
672 fn test_fallback_config_get_fallbacks() {
673 let config = FallbackConfig {
674 developer: vec!["claude".to_string(), "codex".to_string()],
675 reviewer: vec!["codex".to_string()],
676 ..Default::default()
677 };
678
679 assert_eq!(
680 config.get_fallbacks(AgentRole::Developer),
681 &["claude", "codex"]
682 );
683 assert_eq!(config.get_fallbacks(AgentRole::Reviewer), &["codex"]);
684
685 assert_eq!(
687 config.get_fallbacks(AgentRole::Analysis),
688 &["claude", "codex"]
689 );
690 }
691
692 #[test]
693 fn test_fallback_config_has_fallbacks() {
694 let config = FallbackConfig {
695 developer: vec!["claude".to_string()],
696 reviewer: vec![],
697 ..Default::default()
698 };
699
700 assert!(config.has_fallbacks(AgentRole::Developer));
701 assert!(config.has_fallbacks(AgentRole::Analysis));
702 assert!(!config.has_fallbacks(AgentRole::Reviewer));
703 }
704
705 #[test]
706 fn test_fallback_config_defaults_provider_fallback() {
707 let config = FallbackConfig::default();
708 assert!(config.get_provider_fallbacks("opencode").is_empty());
709 assert!(!config.has_provider_fallbacks("opencode"));
710 }
711
712 #[test]
713 fn test_provider_fallback_config() {
714 let provider_fallback = HashMap::from([(
715 "opencode".to_string(),
716 vec![
717 "-m opencode/glm-4.7-free".to_string(),
718 "-m opencode/claude-sonnet-4".to_string(),
719 ],
720 )]);
721
722 let config = FallbackConfig {
723 provider_fallback,
724 ..Default::default()
725 };
726
727 let fallbacks = config.get_provider_fallbacks("opencode");
728 assert_eq!(fallbacks.len(), 2);
729 assert_eq!(fallbacks[0], "-m opencode/glm-4.7-free");
730 assert_eq!(fallbacks[1], "-m opencode/claude-sonnet-4");
731
732 assert!(config.has_provider_fallbacks("opencode"));
733 assert!(!config.has_provider_fallbacks("claude"));
734 }
735
736 #[test]
737 fn test_fallback_config_from_toml() {
738 let toml_str = r#"
739 developer = ["claude", "codex"]
740 reviewer = ["codex", "claude"]
741 max_retries = 5
742 retry_delay_ms = 2000
743
744 [provider_fallback]
745 opencode = ["-m opencode/glm-4.7-free", "-m zai/glm-4.7"]
746 "#;
747
748 let config: FallbackConfig = toml::from_str(toml_str).unwrap();
749 assert_eq!(config.developer, vec!["claude", "codex"]);
750 assert_eq!(config.reviewer, vec!["codex", "claude"]);
751 assert_eq!(config.max_retries, 5);
752 assert_eq!(config.retry_delay_ms, 2000);
753 assert_eq!(config.get_provider_fallbacks("opencode").len(), 2);
754 }
755
756 #[test]
757 fn test_commit_uses_reviewer_chain_when_empty() {
758 let config = FallbackConfig {
760 commit: vec![],
761 reviewer: vec!["agent1".to_string(), "agent2".to_string()],
762 ..Default::default()
763 };
764
765 assert_eq!(
767 config.get_fallbacks(AgentRole::Commit),
768 &["agent1", "agent2"]
769 );
770 assert!(config.has_fallbacks(AgentRole::Commit));
771 }
772
773 #[test]
774 fn test_commit_uses_own_chain_when_configured() {
775 let config = FallbackConfig {
777 commit: vec!["commit-agent".to_string()],
778 reviewer: vec!["reviewer-agent".to_string()],
779 ..Default::default()
780 };
781
782 assert_eq!(config.get_fallbacks(AgentRole::Commit), &["commit-agent"]);
784 }
785}