1pub mod config;
16mod llm;
17mod ollama;
18mod selection;
19mod strategies;
20pub mod weights;
21
22pub use config::{parse_pool_entry, RouterConfig};
23pub use weights::AgentWeights;
24
25#[derive(Debug, thiserror::Error)]
30pub enum AllAgentsCooledError {
31 #[error("all agents/models in cooldown for {scope}")]
32 AllCooled {
33 scope: String,
34 remaining_secs: Option<u64>,
35 },
36}
37
38impl AllAgentsCooledError {
39 pub fn new(scope: String, remaining_secs: Option<u64>) -> Self {
41 Self::AllCooled {
42 scope,
43 remaining_secs,
44 }
45 }
46
47 pub fn scope(&self) -> &str {
51 match self {
52 Self::AllCooled { scope, .. } => scope,
53 }
54 }
55
56 pub fn remaining_secs(&self) -> Option<u64> {
58 match self {
59 Self::AllCooled { remaining_secs, .. } => *remaining_secs,
60 }
61 }
62}
63
64#[derive(Debug, thiserror::Error)]
66pub enum RouteResultError {
67 #[error("no agent configured for task {task_id}")]
69 NoAgent { task_id: String },
70 #[error(transparent)]
72 Other(#[from] anyhow::Error),
73}
74
75use crate::backends::ExternalTask;
76use crate::engine::cooldown::{
77 cooldown_until, is_agent_degraded, is_agent_in_cooldown, is_model_in_cooldown,
78 record_agent_success, refresh_degraded_agents, sync_from_kv,
79};
80use crate::store::{get_task_field_direct, store_log_activity, TaskStore};
81use serde::{Deserialize, Serialize};
82use std::sync::Arc;
83
84use llm::{LlmRouter, TIMEOUT_PREFIX};
85
86#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
88pub struct RouteResult {
89 pub agent: String,
91 pub model: Option<String>,
93 pub complexity: String,
95 pub estimate: u8,
97 pub reason: String,
99 pub profile: AgentProfile,
101 pub selected_skills: Vec<String>,
103 pub warning: Option<String>,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
109pub struct AgentProfile {
110 pub role: String,
112 pub skills: Vec<String>,
114 pub tools: Vec<String>,
116 pub constraints: Vec<String>,
118}
119
120impl AgentProfile {
121 pub fn default_from_config(config: &RouterConfig) -> Self {
123 Self {
124 role: "general".to_string(),
125 skills: vec![],
126 tools: config.allowed_tools.clone(),
127 constraints: vec![],
128 }
129 }
130}
131
132impl RouteResult {
133 pub fn from_config(
138 agent: String,
139 complexity: String,
140 reason: String,
141 config: &RouterConfig,
142 task_id: &str,
143 ) -> Self {
144 let model = config.model_for_complexity(&agent, &complexity, task_id);
145 Self {
146 agent,
147 model,
148 complexity,
149 estimate: 0,
150 reason,
151 profile: AgentProfile::default_from_config(config),
152 selected_skills: config.default_skills.clone(),
153 warning: None,
154 }
155 }
156}
157
158pub struct LlmRouterHandle(std::sync::Arc<LlmRouter>);
164
165impl LlmRouterHandle {
166 pub async fn invalidate_skills_catalog(&self) {
169 self.0.invalidate_skills_catalog().await;
170 }
171}
172
173pub struct Router {
175 pub config: RouterConfig,
177 pub available_agents: Vec<String>,
179 pub weights: AgentWeights,
181 llm_router: std::sync::Arc<LlmRouter>,
184 ollama_router: Option<ollama::OllamaRouter>,
186 pub(crate) rr_index: usize,
188 pub(crate) last_agent: Option<String>,
190 pub(crate) review_rr_index: usize,
192 pub(crate) router_pool: Vec<(String, String)>,
195 pub(crate) pool_index: usize,
197}
198
199impl Router {
200 pub fn new(config: RouterConfig) -> Self {
202 let available_agents = Self::discover_agents(&config.agents);
203 let mut weights = AgentWeights {
204 base_weights: config.weights.clone(),
205 ..Default::default()
206 };
207 weights.ensure_agents_with_weights(&available_agents, &config.weights);
208 if !config.weights.is_empty() {
209 tracing::info!(
210 weights = ?config.weights,
211 "configured agent routing weights"
212 );
213 }
214 let router_pool = Self::expand_pool(&config);
215 tracing::info!(
216 pool = ?router_pool,
217 fallback = %config.effective_fallback(),
218 "router LLM pool initialized"
219 );
220 crate::engine::runner::agents::opencode::prime_free_model_cache();
224 let ollama_router = if config.mode == "local" {
225 Some(ollama::OllamaRouter::new(&config))
226 } else {
227 None
228 };
229 Self {
230 config,
231 available_agents,
232 weights,
233 llm_router: std::sync::Arc::new(LlmRouter::new()),
234 ollama_router,
235 rr_index: 0,
236 last_agent: None,
237 review_rr_index: 0,
238 router_pool,
239 pool_index: 0,
240 }
241 }
242
243 pub fn from_config() -> Self {
245 Self::new(RouterConfig::from_config())
246 }
247
248 #[cfg(test)]
251 pub(crate) fn new_for_test(config: RouterConfig, available_agents: Vec<String>) -> Self {
252 let mut weights = AgentWeights {
253 base_weights: config.weights.clone(),
254 ..Default::default()
255 };
256 weights.ensure_agents(&available_agents);
257 let router_pool = Self::expand_pool(&config);
258 Self {
259 config,
260 available_agents,
261 weights,
262 llm_router: std::sync::Arc::new(LlmRouter::new()),
263 ollama_router: None,
264 rr_index: 0,
265 last_agent: None,
266 review_rr_index: 0,
267 router_pool,
268 pool_index: 0,
269 }
270 }
271
272 pub fn llm_router_handle(&self) -> LlmRouterHandle {
280 LlmRouterHandle(self.llm_router.clone())
281 }
282
283 fn advance_pool_index_after_attempt(&mut self, idx: usize, pool_len: usize) {
284 debug_assert!(pool_len > 0, "router pool must not be empty");
285 self.pool_index = (idx + 1) % pool_len;
286 }
287
288 fn discover_agents(configured_agents: &[String]) -> Vec<String> {
291 let mut agents = Vec::new();
292 for agent in configured_agents {
293 if crate::cmd_cache::command_exists(agent) {
294 agents.push(agent.to_string());
295 }
296 }
297 agents
298 }
299
300 fn expand_pool(config: &RouterConfig) -> Vec<(String, String)> {
307 let raw_pool = config.effective_pool();
308 let mut expanded: Vec<(String, String)> = Vec::new();
309
310 for entry in &raw_pool {
311 if entry == "opencode:free" {
312 let free_models = Self::discover_free_opencode_models();
313 if free_models.is_empty() {
314 tracing::debug!("opencode:free expanded to nothing — skipping");
315 } else {
316 tracing::debug!(models = ?free_models, "opencode:free expanded");
317 for model in free_models {
318 expanded.push(("opencode".to_string(), model));
319 }
320 }
321 } else {
322 let (agent, model) = parse_pool_entry(entry);
323 expanded.push((agent, model));
324 }
325 }
326
327 if expanded.is_empty() {
328 let (agent, model) = parse_pool_entry(&config.effective_fallback());
330 tracing::debug!(
331 agent = %agent, model = %model,
332 "pool empty after expansion, using fallback as sole pool entry"
333 );
334 expanded.push((agent, model));
335 }
336
337 expanded
338 }
339
340 async fn expand_pool_async(config: &RouterConfig) -> Vec<(String, String)> {
343 let raw_pool = config.effective_pool();
344 let mut expanded: Vec<(String, String)> = Vec::new();
345
346 for entry in &raw_pool {
347 if entry == "opencode:free" {
348 let free_models = Self::discover_free_opencode_models_async().await;
349 if free_models.is_empty() {
350 tracing::debug!("opencode:free expanded to nothing — skipping (async)");
351 } else {
352 tracing::debug!(models = ?free_models, "opencode:free expanded (async)");
353 for model in free_models {
354 expanded.push(("opencode".to_string(), model));
355 }
356 }
357 } else {
358 let (agent, model) = parse_pool_entry(entry);
359 expanded.push((agent, model));
360 }
361 }
362
363 if expanded.is_empty() {
364 let (agent, model) = parse_pool_entry(&config.effective_fallback());
366 tracing::debug!(
367 agent = %agent, model = %model,
368 "pool empty after async expansion, using fallback as sole pool entry"
369 );
370 expanded.push((agent, model));
371 }
372
373 expanded
374 }
375
376 pub async fn reload_async(&mut self) {
379 let new_config = RouterConfig::from_config();
380 let new_agents = Self::discover_agents(&new_config.agents);
381 let new_pool = Self::expand_pool_async(&new_config).await;
382 tracing::info!(
383 mode = %new_config.mode,
384 agents = ?new_agents,
385 fallback = %new_config.fallback_executor,
386 weighted_rr = new_config.weighted_round_robin,
387 pool = ?new_pool,
388 "router reloaded (async)"
389 );
390 self.config = new_config;
391 self.available_agents = new_agents.clone();
392 self.weights.ensure_agents(&new_agents);
394 self.router_pool = new_pool;
395 self.pool_index = 0;
396 self.ollama_router = if self.config.mode == "local" {
398 Some(ollama::OllamaRouter::new(&self.config))
399 } else {
400 None
401 };
402 }
403
404 fn discover_free_opencode_models() -> Vec<String> {
405 crate::engine::runner::agents::opencode::discover_free_opencode_models()
406 }
407
408 async fn discover_free_opencode_models_async() -> Vec<String> {
409 tokio::task::spawn_blocking(Self::discover_free_opencode_models)
410 .await
411 .unwrap_or_default()
412 }
413
414 pub async fn refresh_health(&self, store: &std::sync::Arc<TaskStore>) {
419 sync_from_kv(store).await;
426
427 let agents = self.available_agents.clone();
428 let model_checker = |agent: &str| -> bool { self.agent_has_any_available_model(agent) };
429 refresh_degraded_agents(
430 store,
431 &agents,
432 &model_checker,
433 config::health_check_window_hours(),
434 config::degraded_rate_limit_threshold(),
435 )
436 .await;
437 }
438
439 pub fn is_agent_available(&self, agent: &str) -> bool {
441 self.available_agents.contains(&agent.to_string())
442 }
443
444 fn agent_has_any_available_model(&self, agent: &str) -> bool {
448 ["simple", "medium", "complex", "review"]
449 .iter()
450 .any(|comp| self.config.has_available_model_for_complexity(agent, comp))
451 }
452
453 fn agent_has_available_model_for_coding_tiers(&self, agent: &str) -> bool {
462 ["medium", "complex"]
463 .iter()
464 .all(|comp| self.config.has_available_model_for_complexity(agent, comp))
465 }
466
467 fn agent_is_routable(&self, agent: &str, complexity: &str) -> bool {
468 if !self.is_agent_available(agent) {
469 return false;
470 }
471 if is_agent_in_cooldown(agent) {
472 tracing::debug!(agent, "agent skipped: in cooldown");
473 return false;
474 }
475 if is_agent_degraded(agent) {
476 tracing::debug!(agent, "agent skipped: degraded (pre-emptive health check)");
477 return false;
478 }
479 if self.config.weighted_round_robin {
483 let weight = self.weights.get_weight(agent);
484 if weight < self.config.skip_limited_threshold {
485 tracing::debug!(
486 agent,
487 weight,
488 "agent considered degraded by weight threshold, skipping"
489 );
490 return false;
491 }
492 }
493 if !self
494 .config
495 .has_available_model_for_complexity(agent, complexity)
496 {
497 tracing::debug!(agent, complexity, "agent skipped: no available model");
498 return false;
499 }
500 true
501 }
502
503 fn available_agents_for_complexity(&self, complexity: &str) -> Vec<String> {
504 self.available_agents
505 .iter()
506 .filter(|agent| self.agent_is_routable(agent, complexity))
507 .cloned()
508 .collect()
509 }
510
511 pub fn healthy_agent_count(&self, complexity: &str) -> usize {
514 self.available_agents_for_complexity(complexity).len()
515 }
516
517 pub fn is_degraded(&self, threshold: usize) -> bool {
520 self.healthy_agent_count("simple") < threshold
521 }
522
523 pub fn earliest_cooldown_until(&self, complexity: Option<&str>) -> Option<i64> {
524 let mut earliest: Option<i64> = None;
525
526 for agent in &self.available_agents {
527 if let Some(until) = cooldown_until(agent) {
528 earliest = Some(earliest.map_or(until, |current| current.min(until)));
529 }
530
531 if let Some(comp) = complexity {
532 if let Some(pool) = self.config.model_pool_for_complexity(agent, comp) {
533 for model in pool {
534 let key = format!("{agent}:{model}");
535 if let Some(until) = cooldown_until(&key) {
536 earliest = Some(earliest.map_or(until, |current| current.min(until)));
537 }
538 }
539 }
540 }
541 }
542
543 earliest
544 }
545
546 fn earliest_pool_cooldown(&self) -> Option<i64> {
551 let mut earliest: Option<i64> = None;
552 for (agent, model) in &self.router_pool {
553 let key = format!("{agent}:{model}");
554 if let Some(until) = cooldown_until(&key) {
555 earliest = Some(earliest.map_or(until, |current| current.min(until)));
556 }
557 }
558 earliest
559 }
560
561 async fn wait_for_cooldown(&self, complexity: Option<&str>) -> anyhow::Result<()> {
562 let now = chrono::Utc::now().timestamp();
569 let Some(earliest) = self.earliest_cooldown_until(complexity) else {
570 tracing::warn!(
574 scope = complexity.unwrap_or("all agents"),
575 "all agents degraded (no cooldown timestamp available) — skipping task, will retry after backoff"
576 );
577 return Err(AllAgentsCooledError::new(
579 complexity.unwrap_or("all agents").to_string(),
580 None,
581 )
582 .into());
583 };
584
585 let remaining = earliest.saturating_sub(now);
586 if remaining > 0 {
587 let scope_str = complexity.unwrap_or("all agents").to_string();
588 tracing::warn!(
589 remaining_secs = remaining,
590 scope = %scope_str,
591 "all agents/models cooled — routing is unavailable until cooldown expires; failing fast to let tick retry"
592 );
593 return Err(AllAgentsCooledError::new(scope_str, Some(remaining as u64)).into());
596 }
597
598 Ok(())
599 }
600
601 pub fn next_round_robin_agent(&mut self, exclude: &[&str], complexity: &str) -> Option<String> {
606 let candidates = self.available_agents_for_complexity(complexity);
609 if candidates.is_empty() {
610 return None;
611 }
612 let idx = self.review_rr_index;
613 let n = candidates.len();
614
615 let agent = (0..n)
617 .map(|offset| &candidates[(idx + offset) % n])
618 .find(|a| !exclude.contains(&a.as_str()))
619 .cloned()
620 .or_else(|| candidates.first().cloned())?;
622
623 self.review_rr_index = (idx + 1) % candidates.len().max(1);
624 Some(agent)
625 }
626
627 async fn skip_no_code_agent(
631 &self,
632 store: &std::sync::Arc<TaskStore>,
633 repo: &str,
634 task_id: &str,
635 candidates: &[String],
636 ) -> Option<Vec<String>> {
637 let no_code_agent = get_task_field_direct(store, repo, task_id, "no_code_last_agent")
638 .await
639 .unwrap_or_else(|| {
640 tracing::warn!(
641 task_id,
642 "no_code_last_agent DB read returned None — loop detection may be impaired"
643 );
644 String::new()
645 });
646 if no_code_agent.is_empty() || !candidates.iter().any(|a| a == &no_code_agent) {
647 return None;
648 }
649 let filtered: Vec<String> = candidates
650 .iter()
651 .filter(|a| a.as_str() != no_code_agent)
652 .cloned()
653 .collect();
654 if filtered.is_empty() {
655 tracing::warn!(
656 task_id,
657 no_code_agent = %no_code_agent,
658 "all available agents are the no-code agent — cannot skip"
659 );
660 return None;
661 }
662 tracing::info!(
663 task_id,
664 skipped = %no_code_agent,
665 remaining = ?filtered,
666 "skipping no-code agent, routing to alternative"
667 );
668 Some(filtered)
669 }
670
671 pub async fn route(
680 &mut self,
681 task: &ExternalTask,
682 store: &std::sync::Arc<TaskStore>,
683 repo: &str,
684 ) -> anyhow::Result<RouteResult> {
685 loop {
689 let store_agent = match store.get_agent_by_external_id(repo, &task.id.0).await {
695 Ok(Some(agent)) => {
696 if !agent.is_empty() && self.config.agents.iter().any(|cfg| cfg == &agent) {
698 Some(agent)
699 } else {
700 None
701 }
702 }
703 Ok(None) => None,
704 Err(e) => {
705 tracing::warn!(
706 repo,
707 task_id = %task.id.0,
708 err = %e,
709 "failed to fetch stored agent, falling back to label/LLM routing"
710 );
711 None
712 }
713 };
714
715 let resolved_agent = store_agent.or_else(|| {
717 strategies::extract_agent_from_labels(&self.config.agents, &task.labels)
718 });
719
720 if let Some(agent) = resolved_agent {
721 let complexity = strategies::extract_complexity_from_labels(&task.labels);
722 if self.agent_is_routable(&agent, &complexity) {
731 let model = self
732 .config
733 .model_for_complexity(&agent, &complexity, &task.id.0);
734
735 if model.is_some() {
743 tracing::debug!(
744 task_id = %task.id.0,
745 agent = %agent,
746 complexity = %complexity,
747 "routed via label"
748 );
749 let mut result = RouteResult::from_config(
750 agent.clone(),
751 complexity.clone(),
752 format!("label agent:{agent}"),
753 &self.config,
754 &task.id.0,
755 );
756 result.profile.role = format!("{} specialist", agent);
759 result.model = model.clone();
760 self.log_route_activity(store, repo, &task.id.0, &result, None)
761 .await;
762 return Ok(result);
763 } else {
764 tracing::warn!(task_id = %task.id.0, agent = %agent, "label agent has no available model — falling through to LLM routing");
769 }
770 }
771
772 let candidates = self.available_agents_for_complexity(&complexity);
773 if candidates.is_empty() {
774 self.wait_for_cooldown(Some(&complexity)).await?;
775 continue;
776 }
777 }
779
780 let complexity = strategies::extract_complexity_from_labels(&task.labels);
781
782 let mut candidates = self.available_agents_for_complexity(&complexity);
784 if candidates.is_empty() {
785 self.wait_for_cooldown(Some(&complexity)).await?;
786 continue;
787 }
788
789 if let Some(filtered) = self
794 .skip_no_code_agent(store, repo, &task.id.0, &candidates)
795 .await
796 {
797 candidates = filtered;
798 }
799
800 if self.config.weighted_round_robin {
802 let result = strategies::route_via_weighted_round_robin(
803 &candidates,
804 &self.weights,
805 &self.config,
806 task,
807 &mut self.last_agent,
808 )?;
809 self.log_route_activity(store, repo, &task.id.0, &result, None)
810 .await;
811 return Ok(result);
812 }
813
814 if self.config.mode == "round_robin" {
816 tracing::debug!(task_id = %task.id.0, "routing via round-robin mode");
817 let result = strategies::route_via_round_robin_stateful(
818 &candidates,
819 &self.config,
820 task,
821 &mut self.rr_index,
822 &mut self.last_agent,
823 )?;
824 self.log_route_activity(store, repo, &task.id.0, &result, None)
825 .await;
826 return Ok(result);
827 }
828
829 if self.config.mode == "local" {
831 tracing::debug!(task_id = %task.id.0, ollama_url = %self.config.ollama_url, "routing via local Ollama");
832 let ollama_router = self
833 .ollama_router
834 .as_ref()
835 .expect("ollama_router must be initialized in Router::new when mode=local");
836 match ollama_router.route(task, &self.config).await {
837 Ok(result) => {
838 self.log_route_activity(store, repo, &task.id.0, &result, None)
839 .await;
840 return Ok(result);
841 }
842 Err(e) => {
843 tracing::warn!(
844 task_id = %task.id.0,
845 error = %e,
846 "Ollama routing failed, falling back to round-robin"
847 );
848 let result = strategies::route_via_round_robin_stateful(
849 &candidates,
850 &self.config,
851 task,
852 &mut self.rr_index,
853 &mut self.last_agent,
854 )?;
855 self.log_route_activity(store, repo, &task.id.0, &result, None)
856 .await;
857 return Ok(result);
858 }
859 }
860 }
861
862 let route_attempts = self.get_route_attempts(&task.id.0, store, repo).await;
864
865 if route_attempts >= self.config.max_route_attempts {
866 tracing::warn!(
867 task_id = %task.id.0,
868 attempts = route_attempts,
869 max = self.config.max_route_attempts,
870 "max LLM route attempts reached, falling back to round-robin"
871 );
872 let candidates = self.available_agents_for_complexity(&complexity);
873 if candidates.is_empty() {
874 self.wait_for_cooldown(Some(&complexity)).await?;
875 continue;
876 }
877 let result = strategies::route_via_round_robin_stateful(
878 &candidates,
879 &self.config,
880 task,
881 &mut self.rr_index,
882 &mut self.last_agent,
883 )?;
884 self.log_route_activity(store, repo, &task.id.0, &result, None)
885 .await;
886 return Ok(result);
887 }
888
889 tracing::debug!(task_id = %task.id.0, "starting LLM routing");
891
892 match self.route_with_llm(task, repo).await {
893 Ok(result) => {
894 let candidates = self.available_agents_for_complexity(&result.complexity);
895 if candidates.is_empty() {
896 self.wait_for_cooldown(Some(&result.complexity)).await?;
897 continue;
898 }
899
900 let no_code_agent =
903 get_task_field_direct(store, repo, &task.id.0, "no_code_last_agent")
904 .await
905 .unwrap_or_else(|| {
906 tracing::warn!(task_id = %task.id.0, "no_code_last_agent DB read returned None — loop detection may be impaired");
907 String::new()
908 });
909 if result.agent == no_code_agent {
910 let fallback_agent = candidates
912 .iter()
913 .find(|a| a.as_str() != no_code_agent)
914 .cloned()
915 .unwrap_or_else(|| candidates[0].clone());
916 if fallback_agent.as_str() == no_code_agent {
917 tracing::warn!(
918 task_id = %task.id.0,
919 no_code_agent = %no_code_agent,
920 complexity = %result.complexity,
921 "all available agents are the no-code agent — waiting for another agent"
922 );
923 self.wait_for_cooldown(Some(&result.complexity)).await?;
924 continue;
925 }
926 tracing::info!(
927 task_id = %task.id.0,
928 llm_selected = %result.agent,
929 skipped = %no_code_agent,
930 fallback = %fallback_agent,
931 "LLM selected the no-code agent — routing to alternative"
932 );
933 let fallback_model = self.config.model_for_complexity(
934 &fallback_agent,
935 &result.complexity,
936 &task.id.0,
937 );
938
939 self.set_route_attempts(&task.id.0, 0, store, repo).await;
940 let result = RouteResult {
941 agent: fallback_agent.clone(),
942 model: fallback_model,
943 complexity: result.complexity.clone(),
944 estimate: result.estimate,
945 reason: format!(
946 "LLM selected no-code agent {}; routed to {}",
947 result.agent, fallback_agent
948 ),
949 profile: result.profile.clone(),
950 selected_skills: result.selected_skills.clone(),
951 warning: Some(format!(
952 "LLM selected no-code agent {}; used fallback {}",
953 result.agent, fallback_agent
954 )),
955 };
956 self.log_route_activity(store, repo, &task.id.0, &result, None)
957 .await;
958 return Ok(result);
959 }
960
961 let model_cooled = result
962 .model
963 .as_deref()
964 .is_some_and(|model| is_model_in_cooldown(&result.agent, model));
965 if !candidates.contains(&result.agent) || model_cooled {
966 let fallback_agent = candidates
967 .iter()
968 .find(|agent| *agent != &result.agent)
969 .cloned()
970 .unwrap_or_else(|| candidates[0].clone());
971 let fallback_model = self.config.model_for_complexity(
972 &fallback_agent,
973 &result.complexity,
974 &task.id.0,
975 );
976
977 if fallback_model.is_none() && fallback_agent == result.agent {
983 tracing::warn!(
984 task_id = %task.id.0,
985 agent = %result.agent,
986 complexity = %result.complexity,
987 "LLM-selected model(s) are cooled for the only available agent; waiting for cooldown"
988 );
989 self.wait_for_cooldown(Some(&result.complexity)).await?;
990 continue;
991 }
992
993 if fallback_agent == result.agent {
997 tracing::warn!(
998 task_id = %task.id.0,
999 agent = %result.agent,
1000 complexity = %result.complexity,
1001 "LLM-selected model was cooled; selecting an alternative model for the same agent"
1002 );
1003 } else {
1004 tracing::warn!(
1005 task_id = %task.id.0,
1006 agent = %result.agent,
1007 fallback = %fallback_agent,
1008 complexity = %result.complexity,
1009 "LLM selected cooled agent/model; rerouting to available agent"
1010 );
1011 }
1012
1013 self.set_route_attempts(&task.id.0, 0, store, repo).await;
1015
1016 let reason = if fallback_agent == result.agent {
1017 format!(
1018 "LLM selected cooled model; using same agent {} with different model",
1019 fallback_agent
1020 )
1021 } else {
1022 format!(
1023 "LLM selected cooled agent/model; rerouted to {}",
1024 fallback_agent
1025 )
1026 };
1027
1028 let result = RouteResult {
1029 agent: fallback_agent.clone(),
1030 model: fallback_model,
1031 complexity: result.complexity.clone(),
1032 estimate: result.estimate,
1033 reason: reason.clone(),
1034 profile: result.profile.clone(),
1035 selected_skills: result.selected_skills.clone(),
1036 warning: Some(if fallback_agent == result.agent {
1037 "LLM-selected model was cooled; selected alternative model for same agent"
1038 .to_string()
1039 } else {
1040 "LLM-selected agent/model was cooled; rerouted to available agent"
1041 .to_string()
1042 }),
1043 };
1044 self.log_route_activity(store, repo, &task.id.0, &result, None)
1045 .await;
1046 return Ok(result);
1047 }
1048
1049 self.set_route_attempts(&task.id.0, 0, store, repo).await;
1051 tracing::info!(
1052 task_id = %task.id.0,
1053 agent = %result.agent,
1054 complexity = %result.complexity,
1055 "routed via LLM"
1056 );
1057 self.log_route_activity(store, repo, &task.id.0, &result, None)
1058 .await;
1059 return Ok(result);
1060 }
1061 Err(e) => {
1062 if let Some(err) = e.downcast_ref::<AllAgentsCooledError>() {
1063 let scope = err.scope();
1064 tracing::warn!(scope = %scope, "router cooldown gate tripped");
1065 let scope_opt = match scope {
1069 "all agents" | "router pool" => None,
1070 _ => Some(scope),
1071 };
1072 self.wait_for_cooldown(scope_opt).await?;
1073 continue;
1074 }
1075
1076 let new_attempts = route_attempts + 1;
1077 self.set_route_attempts(&task.id.0, new_attempts, store, repo)
1078 .await;
1079 tracing::warn!(
1080 task_id = %task.id.0,
1081 error = %e,
1082 error_chain = ?e,
1083 attempt = new_attempts,
1084 max = self.config.max_route_attempts,
1085 "LLM routing failed"
1086 );
1087
1088 if new_attempts >= self.config.max_route_attempts {
1089 tracing::info!(
1090 task_id = %task.id.0,
1091 "falling back to round-robin after {} failed attempts",
1092 new_attempts
1093 );
1094 let candidates = self.available_agents_for_complexity(&complexity);
1095 if candidates.is_empty() {
1096 self.wait_for_cooldown(Some(&complexity)).await?;
1097 continue;
1098 }
1099 let result = strategies::route_via_round_robin_stateful(
1100 &candidates,
1101 &self.config,
1102 task,
1103 &mut self.rr_index,
1104 &mut self.last_agent,
1105 )?;
1106 self.log_route_activity(store, repo, &task.id.0, &result, None)
1107 .await;
1108 return Ok(result);
1109 }
1110
1111 let candidates = self.available_agents_for_complexity(&complexity);
1112 if candidates.is_empty() {
1113 self.wait_for_cooldown(Some(&complexity)).await?;
1114 continue;
1115 }
1116 let result = strategies::route_via_fallback(
1117 &candidates,
1118 &self.config,
1119 task,
1120 Some(&self.weights),
1121 )?;
1122 self.log_route_activity(store, repo, &task.id.0, &result, None)
1123 .await;
1124 return Ok(result);
1125 }
1126 }
1127 }
1128 }
1129
1130 async fn get_route_attempts(
1132 &self,
1133 task_id: &str,
1134 store: &std::sync::Arc<TaskStore>,
1135 repo: &str,
1136 ) -> u32 {
1137 match store.resolve_task_id(repo, task_id).await {
1138 Ok(Some(store_id)) => store
1139 .get(store_id)
1140 .await
1141 .map(|t| t.route_attempts as u32)
1142 .unwrap_or(0),
1143 _ => 0,
1144 }
1145 }
1146
1147 async fn set_route_attempts(
1149 &self,
1150 task_id: &str,
1151 attempts: u32,
1152 store: &std::sync::Arc<TaskStore>,
1153 repo: &str,
1154 ) {
1155 if let Ok(Some(store_id)) = store.resolve_task_id(repo, task_id).await {
1156 if let Err(e) = store
1157 .set_fields(store_id, &[("route_attempts", serde_json::json!(attempts))])
1158 .await
1159 {
1160 tracing::warn!(task_id, error = %e, "failed to store route_attempts");
1161 }
1162 }
1163 }
1164
1165 async fn log_route_activity(
1167 &self,
1168 store: &std::sync::Arc<TaskStore>,
1169 repo: &str,
1170 task_id: &str,
1171 result: &RouteResult,
1172 from_agent: Option<&str>,
1173 ) {
1174 let details = serde_json::json!({
1175 "reason": result.reason,
1176 "complexity": result.complexity,
1177 "skills": result.selected_skills,
1178 "role": result.profile.role,
1179 "warning": result.warning,
1180 });
1181 let event_type = if from_agent.is_some() {
1182 "rerouted"
1183 } else {
1184 "routed"
1185 };
1186 store_log_activity(
1187 &Some(Arc::clone(store)),
1188 repo,
1189 task_id,
1190 event_type,
1191 None,
1192 Some("routed"),
1193 Some(&result.agent),
1194 result.model.as_deref(),
1195 Some(&details),
1196 )
1197 .await;
1198 }
1199
1200 async fn route_with_llm(
1206 &mut self,
1207 task: &ExternalTask,
1208 repo: &str,
1209 ) -> anyhow::Result<RouteResult> {
1210 let uncooled_agents: Vec<String> = self
1220 .available_agents
1221 .iter()
1222 .filter(|a| {
1223 !is_agent_in_cooldown(a)
1224 && !is_agent_degraded(a)
1225 && self.agent_has_available_model_for_coding_tiers(a)
1226 })
1227 .cloned()
1228 .collect();
1229 if uncooled_agents.is_empty() {
1230 return Err(AllAgentsCooledError::new("all agents".to_string(), None).into());
1231 }
1232 let llm_agents = uncooled_agents;
1233
1234 let pool = self.router_pool.clone();
1235 let n = pool.len();
1236 let start = self.pool_index;
1237 let mut last_err: Option<anyhow::Error> = None;
1238 let mut attempted_any = false;
1239
1240 for i in 0..n {
1242 let idx = (start + i) % n;
1243 let (agent, model) = &pool[idx];
1244 let model_str = model.as_str();
1245
1246 if crate::engine::runner::response::is_model_in_cooldown(agent, model_str) {
1247 tracing::debug!(agent, model = model_str, "pool entry on cooldown, skipping");
1248 continue;
1249 }
1250
1251 attempted_any = true;
1253
1254 let model_opt = if model_str.is_empty() {
1255 None
1256 } else {
1257 Some(model_str)
1258 };
1259
1260 match self
1261 .llm_router
1262 .route_with_llm_using(
1263 task,
1264 &llm_agents,
1265 &self.config,
1266 &mut self.last_agent,
1267 repo,
1268 agent,
1269 model_opt,
1270 )
1271 .await
1272 {
1273 Ok(result) => {
1274 self.advance_pool_index_after_attempt(idx, n);
1276 tracing::debug!(
1277 agent,
1278 model = model_str,
1279 pool_idx = idx,
1280 "router LLM pool entry succeeded"
1281 );
1282 record_agent_success(agent, model_str).await;
1283 return Ok(result);
1284 }
1285 Err(e) => {
1286 let err_msg = e.to_string();
1287 let is_timeout = err_msg.contains(TIMEOUT_PREFIX);
1288 if is_timeout {
1289 self.advance_pool_index_after_attempt(idx, n);
1299 tracing::warn!(
1300 agent,
1301 model = model_str,
1302 next_pool_idx = self.pool_index,
1303 "router LLM pool entry timed out — recording cooldown, will retry next tick"
1304 );
1305 crate::engine::runner::response::record_model_failure(agent, model_str)
1306 .await;
1307 return Err(e);
1308 } else {
1309 tracing::warn!(
1310 agent,
1311 model = model_str,
1312 error = %e,
1313 "pool entry failed, recording cooldown and trying next"
1314 );
1315 crate::engine::runner::response::record_model_failure(agent, model_str)
1316 .await;
1317 }
1318 last_err = Some(e);
1319 self.advance_pool_index_after_attempt(idx, n);
1320 }
1321 }
1322 }
1323
1324 if !attempted_any {
1329 let now = chrono::Utc::now().timestamp();
1330 let earliest = self.earliest_pool_cooldown();
1331 if let Some(until) = earliest {
1332 let remaining = until.saturating_sub(now);
1334 tracing::warn!(
1335 remaining_secs = remaining,
1336 pool = ?pool,
1337 "all router LLM pool entries on cooldown — will try fallback"
1338 );
1339 } else {
1341 return Err(last_err
1344 .unwrap_or_else(|| anyhow::anyhow!("all router LLM pool entries exhausted")));
1345 }
1346 }
1347
1348 let fallback = self.config.effective_fallback();
1350 let (fb_agent, fb_model) = parse_pool_entry(&fallback);
1351 let fb_model_str = fb_model.as_str();
1352
1353 let fallback_already_tried = pool.iter().any(|(a, m)| a == &fb_agent && m == &fb_model);
1355
1356 if !fallback_already_tried
1357 && !crate::engine::runner::response::is_model_in_cooldown(&fb_agent, fb_model_str)
1358 {
1359 tracing::info!(
1360 agent = %fb_agent,
1361 model = %fb_model_str,
1362 "all pool entries exhausted — trying fallback router LLM"
1363 );
1364 let fb_model_opt = if fb_model_str.is_empty() {
1365 None
1366 } else {
1367 Some(fb_model_str)
1368 };
1369 match self
1370 .llm_router
1371 .route_with_llm_using(
1372 task,
1373 &llm_agents,
1374 &self.config,
1375 &mut self.last_agent,
1376 repo,
1377 &fb_agent,
1378 fb_model_opt,
1379 )
1380 .await
1381 {
1382 Ok(result) => {
1383 record_agent_success(&fb_agent, fb_model_str).await;
1384 return Ok(result);
1385 }
1386 Err(e) => {
1387 let err_msg = e.to_string();
1388 let is_timeout = err_msg.contains(TIMEOUT_PREFIX);
1389 if is_timeout {
1390 crate::engine::runner::response::record_model_failure(
1393 &fb_agent,
1394 fb_model_str,
1395 )
1396 .await;
1397 tracing::warn!(
1398 agent = %fb_agent,
1399 model = %fb_model_str,
1400 "fallback router LLM timed out — returning immediately to unblock tick loop"
1401 );
1402 return Err(e);
1403 } else {
1404 tracing::warn!(
1405 agent = %fb_agent,
1406 model = %fb_model_str,
1407 error = %e,
1408 "fallback router LLM also failed"
1409 );
1410 crate::engine::runner::response::record_model_failure(
1411 &fb_agent,
1412 fb_model_str,
1413 )
1414 .await;
1415 }
1416 last_err = Some(e);
1417 }
1418 }
1419 }
1420
1421 Err(last_err.unwrap_or_else(|| anyhow::anyhow!("all router LLM pool entries exhausted")))
1422 }
1423
1424 pub fn record_rate_limit(&mut self, agent: &str) {
1428 self.weights.record_rate_limit(agent);
1429 }
1430
1431 pub fn record_success(&mut self, agent: &str) {
1433 self.weights.record_success(agent);
1434 }
1435
1436 pub fn tick_weight_recovery(&mut self) {
1441 self.weights.tick_recovery();
1442 }
1443
1444 pub async fn store_route_result(
1446 &self,
1447 task_id: &str,
1448 result: &RouteResult,
1449 store: &std::sync::Arc<TaskStore>,
1450 repo: &str,
1451 ) -> anyhow::Result<()> {
1452 if let Ok(Some(store_id)) = store.resolve_task_id(repo, task_id).await {
1453 store
1454 .set_fields(
1455 store_id,
1456 &[
1457 ("agent", serde_json::json!(result.agent)),
1458 ("complexity", serde_json::json!(result.complexity)),
1459 ("route_reason", serde_json::json!(result.reason)),
1460 (
1461 "model",
1462 serde_json::json!(result.model.as_deref().unwrap_or("")),
1463 ),
1464 ],
1465 )
1466 .await?;
1467 }
1468 Ok(())
1469 }
1470}
1471
1472pub async fn get_route_result(
1474 store: &std::sync::Arc<TaskStore>,
1475 repo: &str,
1476 task_id: &str,
1477) -> Result<RouteResult, RouteResultError> {
1478 let store_id = store
1479 .resolve_task_id(repo, task_id)
1480 .await
1481 .map_err(RouteResultError::Other)?
1482 .ok_or_else(|| {
1483 RouteResultError::Other(anyhow::anyhow!("task {task_id} not found in store"))
1484 })?;
1485 let task = store.get(store_id).await.map_err(RouteResultError::Other)?;
1486
1487 let agent = task
1488 .agent
1489 .clone()
1490 .filter(|a| !a.is_empty())
1491 .ok_or_else(|| RouteResultError::NoAgent {
1492 task_id: task_id.to_string(),
1493 })?;
1494
1495 let complexity = if task.complexity.is_empty() {
1496 "medium".to_string()
1497 } else {
1498 task.complexity.clone()
1499 };
1500
1501 let reason = task.route_reason.clone();
1502 let model = task.model.clone().filter(|m| !m.is_empty());
1503
1504 let config = RouterConfig::from_config();
1508 let model = model.filter(|m| {
1509 let complexities = ["simple", "medium", "complex", "review"];
1510 let all_pools: Vec<Vec<String>> = complexities
1512 .iter()
1513 .filter_map(|comp| config.model_pool_for_complexity(&agent, comp))
1514 .collect();
1515 if all_pools.is_empty() {
1517 return true;
1518 }
1519 let valid = all_pools.iter().any(|pool| pool.contains(m));
1520 if !valid {
1521 tracing::warn!(
1522 task_id,
1523 agent,
1524 model = %m,
1525 "discarding stale model: not in agent's model pools"
1526 );
1527 }
1528 valid
1529 });
1530
1531 let original_model = task.model.clone().filter(|m| !m.is_empty());
1535 if original_model.is_some() && model.is_none() {
1536 let stale_model = original_model.as_deref().unwrap_or("unknown");
1537 let stale_err = format!("stale model discarded: {stale_model} not in {agent} model pools");
1538 if let Err(e) = store
1539 .set_fields(
1540 store_id,
1541 &[
1542 ("model", serde_json::json!("")),
1543 ("last_error", serde_json::json!(stale_err)),
1544 ],
1545 )
1546 .await
1547 {
1548 tracing::warn!(task_id = %task_id, error = %e, "failed to clear stale model from store — will retry on next dispatch");
1550 }
1551 }
1552
1553 Ok(RouteResult {
1554 agent,
1555 model,
1556 complexity,
1557 estimate: 0,
1558 reason,
1559 profile: AgentProfile::default(),
1560 selected_skills: vec![],
1561 warning: None,
1562 })
1563}
1564
1565#[cfg(test)]
1566mod tests {
1567 use super::config::DEFAULT_AGENTS;
1568 use super::llm::LlmRouteResponse;
1569 use super::strategies;
1570 use super::weights::{
1571 AgentWeights, RateLimitState, DEFAULT_WEIGHT, MIN_WEIGHT, RATE_LIMIT_DECAY, RECOVERY_DELAY,
1572 };
1573 use super::*;
1574 use crate::backends::{ExternalId, ExternalTask};
1575 use crate::engine::cooldown::{
1576 clear_agent_degraded, is_agent_degraded, is_model_in_cooldown, mark_agent_degraded,
1577 record_model_failure, set_model_cooldown,
1578 };
1579 use crate::store::TaskStore;
1580 use serial_test::serial;
1581 use std::time::{Duration, Instant};
1582
1583 impl Router {
1586 fn parse_llm_response(&self, response: &str) -> anyhow::Result<LlmRouteResponse> {
1587 self.llm_router.parse_llm_response("claude", response)
1588 }
1589
1590 fn check_routing_sanity(
1591 &self,
1592 task: &ExternalTask,
1593 agent: &str,
1594 profile: &AgentProfile,
1595 ) -> Option<String> {
1596 self.llm_router.check_routing_sanity(task, agent, profile)
1597 }
1598 }
1599
1600 async fn test_store() -> std::sync::Arc<TaskStore> {
1601 std::sync::Arc::new(TaskStore::open_memory().await.unwrap())
1602 }
1603
1604 fn create_test_task(id: &str, title: &str, labels: Vec<String>) -> ExternalTask {
1605 ExternalTask {
1606 id: ExternalId(id.to_string()),
1607 title: title.to_string(),
1608 body: "Test body".to_string(),
1609 state: "open".to_string(),
1610 labels,
1611 author: "test".to_string(),
1612 created_at: "2026-01-01T00:00:00Z".to_string(),
1613 updated_at: "2026-01-01T00:00:00Z".to_string(),
1614 url: format!("https://github.com/test/test/issues/{id}"),
1615 }
1616 }
1617
1618 #[serial(cooldown_state)]
1619 #[test]
1620 fn extract_agent_from_labels() {
1621 let mut config = RouterConfig::default();
1622 config
1625 .model_map
1626 .entry("medium".to_string())
1627 .or_default()
1628 .insert("claude".to_string(), vec!["haiku".to_string()]);
1629
1630 assert_eq!(
1631 strategies::extract_agent_from_labels(&config.agents, &["agent:claude".to_string()]),
1632 Some("claude".to_string())
1633 );
1634 assert_eq!(
1635 strategies::extract_agent_from_labels(&config.agents, &["agent:codex".to_string()]),
1636 Some("codex".to_string())
1637 );
1638 assert_eq!(
1639 strategies::extract_agent_from_labels(&config.agents, &["agent:opencode".to_string()]),
1640 Some("opencode".to_string())
1641 );
1642 assert_eq!(
1643 strategies::extract_agent_from_labels(&config.agents, &["status:new".to_string()]),
1644 None
1645 );
1646 assert_eq!(
1648 strategies::extract_agent_from_labels(&config.agents, &["agent:kimi".to_string()]),
1649 Some("kimi".to_string())
1650 );
1651 assert_eq!(
1652 strategies::extract_agent_from_labels(&config.agents, &["agent:minimax".to_string()]),
1653 Some("minimax".to_string())
1654 );
1655 }
1656
1657 #[serial(cooldown_state)]
1658 #[test]
1659 fn default_agents_constant() {
1660 assert_eq!(DEFAULT_AGENTS.len(), 7);
1661 assert!(DEFAULT_AGENTS.contains(&"claude"));
1662 assert!(DEFAULT_AGENTS.contains(&"kimi"));
1663 assert!(DEFAULT_AGENTS.contains(&"minimax"));
1664 assert!(DEFAULT_AGENTS.contains(&"glm"));
1665 assert!(DEFAULT_AGENTS.contains(&"grok"));
1666 }
1667
1668 #[serial(cooldown_state)]
1669 #[test]
1670 fn extract_complexity_from_labels() {
1671 assert_eq!(
1672 strategies::extract_complexity_from_labels(&["complexity:simple".to_string()]),
1673 "simple"
1674 );
1675 assert_eq!(
1676 strategies::extract_complexity_from_labels(&["complexity:medium".to_string()]),
1677 "medium"
1678 );
1679 assert_eq!(
1680 strategies::extract_complexity_from_labels(&["complexity:complex".to_string()]),
1681 "complex"
1682 );
1683 assert_eq!(
1684 strategies::extract_complexity_from_labels(&["status:new".to_string()]),
1685 "medium"
1686 );
1687 }
1688
1689 #[serial(cooldown_state)]
1690 #[test]
1691 fn route_result_serialization() {
1692 let result = RouteResult {
1693 agent: "claude".to_string(),
1694 model: Some("sonnet".to_string()),
1695 complexity: "medium".to_string(),
1696 estimate: 5,
1697 reason: "test".to_string(),
1698 profile: AgentProfile {
1699 role: "backend".to_string(),
1700 skills: vec!["rust".to_string()],
1701 tools: vec!["git".to_string()],
1702 constraints: vec![],
1703 },
1704 selected_skills: vec!["gh".to_string()],
1705 warning: None,
1706 };
1707
1708 let json = serde_json::to_string(&result).unwrap();
1709 assert!(json.contains("claude"));
1710 assert!(json.contains("sonnet"));
1711
1712 let deserialized: RouteResult = serde_json::from_str(&json).unwrap();
1713 assert_eq!(deserialized.agent, "claude");
1714 assert_eq!(deserialized.model, Some("sonnet".to_string()));
1715 }
1716
1717 #[serial(cooldown_state)]
1718 #[test]
1719 fn router_config_default() {
1720 let mut config = RouterConfig::default();
1721 config
1724 .model_map
1725 .entry("medium".to_string())
1726 .or_default()
1727 .insert("claude".to_string(), vec!["haiku".to_string()]);
1728
1729 assert_eq!(config.mode, "round_robin");
1730 assert_eq!(config.router_agent, "claude");
1731 assert_eq!(config.router_model, "haiku");
1732 assert_eq!(config.fallback_executor, "codex");
1733 assert_eq!(config.max_route_attempts, 3);
1734 assert!(!config.allowed_tools.is_empty());
1735 assert!(!config.default_skills.is_empty());
1736
1737 assert_eq!(config.agents.len(), 7);
1739 assert!(config.agents.contains(&"claude".to_string()));
1740 assert!(config.agents.contains(&"codex".to_string()));
1741 assert!(config.agents.contains(&"opencode".to_string()));
1742 assert!(config.agents.contains(&"kimi".to_string()));
1743 assert!(config.agents.contains(&"minimax".to_string()));
1744 assert!(config.agents.contains(&"glm".to_string()));
1745 assert!(config.agents.contains(&"grok".to_string()));
1746 }
1747
1748 #[serial(cooldown_state)]
1749 #[test]
1750 fn model_map_lookup_returns_none_without_config() {
1751 let config = RouterConfig::default();
1753 assert!(config
1754 .model_for_complexity("claude", "simple", "")
1755 .is_none());
1756 assert!(config
1757 .model_for_complexity("claude", "medium", "")
1758 .is_none());
1759 assert!(config
1760 .model_for_complexity("claude", "complex", "")
1761 .is_none());
1762 assert!(config
1763 .model_for_complexity("opencode", "review", "")
1764 .is_none());
1765 }
1766
1767 #[serial(cooldown_state)]
1768 #[test]
1769 fn model_map_lookup_returns_configured_value() {
1770 let mut config = RouterConfig::default();
1772 config
1773 .model_map
1774 .entry("simple".to_string())
1775 .or_default()
1776 .insert("claude".to_string(), vec!["haiku".to_string()]);
1777 assert_eq!(
1778 config.model_for_complexity("claude", "simple", ""),
1779 Some("haiku".to_string())
1780 );
1781 }
1782
1783 #[serial(cooldown_state)]
1784 #[tokio::test]
1785 async fn model_pool_selection_skips_cooled() {
1786 crate::engine::cooldown::reset_global_state().await;
1787 use std::collections::HashMap;
1788
1789 let mut config = RouterConfig::default();
1790 config
1792 .model_map
1793 .entry("simple".to_string())
1794 .or_default()
1795 .insert(
1796 "opencode".to_string(),
1797 vec!["model-a".to_string(), "model-b".to_string()],
1798 );
1799
1800 let m = config.model_for_complexity("opencode", "simple", "task-1");
1802 assert!(m == Some("model-a".to_string()) || m == Some("model-b".to_string()));
1803
1804 record_model_failure("opencode", "model-a").await;
1806 assert!(is_model_in_cooldown("opencode", "model-a"));
1807
1808 for i in 0..20 {
1811 let result = config.model_for_complexity("opencode", "simple", &i.to_string());
1812 assert_eq!(
1813 result,
1814 Some("model-b".to_string()),
1815 "expected model-b when model-a is cooled, got {result:?} for task_id={i}"
1816 );
1817 }
1818
1819 record_model_failure("opencode", "model-b").await;
1821 let fallback = config.model_for_complexity("opencode", "simple", "task-fallback");
1823 assert_eq!(fallback, None);
1824 let _ = HashMap::<(), ()>::new(); }
1826
1827 #[serial(cooldown_state)]
1828 #[test]
1829 fn model_pool_single_string_backward_compat() {
1830 let mut config = RouterConfig::default();
1833 config
1834 .model_map
1835 .entry("simple".to_string())
1836 .or_default()
1837 .insert("claude".to_string(), vec!["haiku".to_string()]);
1838 let m = config.model_for_complexity("claude", "simple", "any-task");
1839 assert_eq!(m, Some("haiku".to_string()));
1840 }
1841
1842 #[serial(cooldown_state)]
1843 #[test]
1844 fn parse_llm_response_direct_json() {
1845 let config = RouterConfig::default();
1846 let router = Router::new(config);
1847
1848 let response = r#"{
1849 "executor": "claude",
1850 "complexity": "complex",
1851 "reason": "requires architecture analysis",
1852 "profile": {
1853 "role": "architect",
1854 "skills": ["rust", "design"],
1855 "tools": ["git", "rg"],
1856 "constraints": []
1857 },
1858 "selected_skills": ["gh"]
1859 }"#;
1860
1861 let parsed = router.parse_llm_response(response).unwrap();
1862 assert_eq!(parsed.executor, "claude");
1863 assert_eq!(parsed.complexity, "complex");
1864 assert_eq!(parsed.reason, "requires architecture analysis");
1865 assert_eq!(parsed.profile.role, "architect");
1866 }
1867
1868 #[serial(cooldown_state)]
1869 #[test]
1870 fn parse_llm_response_markdown_fenced() {
1871 let config = RouterConfig::default();
1872 let router = Router::new(config);
1873
1874 let response = r#"Here's my analysis:
1875
1876```json
1877{
1878 "executor": "codex",
1879 "complexity": "medium",
1880 "reason": "coding task",
1881 "profile": {
1882 "role": "developer",
1883 "skills": ["coding"],
1884 "tools": [],
1885 "constraints": []
1886 },
1887 "selected_skills": []
1888}
1889```
1890
1891Hope that helps!"#;
1892
1893 let parsed = router.parse_llm_response(response).unwrap();
1894 assert_eq!(parsed.executor, "codex");
1895 assert_eq!(parsed.complexity, "medium");
1896 }
1897
1898 #[serial(cooldown_state)]
1899 #[test]
1900 fn parse_llm_response_claude_envelope() {
1901 let config = RouterConfig::default();
1902 let router = Router::new(config);
1903
1904 let response = r#"{"type":"result","subtype":"success","is_error":false,"duration_ms":1500,"result":"{\"executor\":\"claude\",\"complexity\":\"complex\",\"reason\":\"requires deep analysis\",\"profile\":{\"role\":\"architect\",\"skills\":[],\"tools\":[],\"constraints\":[]},\"selected_skills\":[]}","usage":{"input_tokens":100,"output_tokens":50}}"#;
1906
1907 let parsed = router.parse_llm_response(response).unwrap();
1908 assert_eq!(parsed.executor, "claude");
1909 assert_eq!(parsed.complexity, "complex");
1910 assert_eq!(parsed.reason, "requires deep analysis");
1911 }
1912
1913 #[serial(cooldown_state)]
1914 #[test]
1915 fn parse_llm_response_claude_envelope_with_code_block() {
1916 let config = RouterConfig::default();
1917 let router = Router::new(config);
1918
1919 let inner = "```json\n{\"executor\":\"codex\",\"complexity\":\"simple\",\"reason\":\"simple fix\",\"profile\":{\"role\":\"developer\",\"skills\":[],\"tools\":[],\"constraints\":[]},\"selected_skills\":[]}\n```";
1921 let envelope = serde_json::json!({
1922 "type": "result",
1923 "subtype": "success",
1924 "is_error": false,
1925 "result": inner,
1926 "usage": {"input_tokens": 50, "output_tokens": 30}
1927 });
1928
1929 let parsed = router.parse_llm_response(&envelope.to_string()).unwrap();
1930 assert_eq!(parsed.executor, "codex");
1931 assert_eq!(parsed.complexity, "simple");
1932 }
1933
1934 #[serial(cooldown_state)]
1935 #[test]
1936 fn parse_llm_response_claude_envelope_error() {
1937 let config = RouterConfig::default();
1938 let router = Router::new(config);
1939
1940 let response = r#"{"type":"result","subtype":"error","is_error":true,"result":"rate limit exceeded","usage":{}}"#;
1941
1942 let err = router.parse_llm_response(response).unwrap_err();
1943 assert!(err.to_string().contains("error"), "got: {err}");
1944 }
1945
1946 #[serial(cooldown_state)]
1947 #[test]
1948 fn parse_llm_response_empty() {
1949 let config = RouterConfig::default();
1950 let router = Router::new(config);
1951
1952 let err = router.parse_llm_response("").unwrap_err();
1953 assert!(err.to_string().contains("empty"), "got: {err}");
1954 }
1955
1956 #[serial(cooldown_state)]
1959 #[test]
1960 fn parse_llm_response_claude_envelope_result_as_object() {
1961 let config = RouterConfig::default();
1962 let router = Router::new(config);
1963
1964 let response = r#"{"type":"result","subtype":"success","is_error":false,"result":{"executor":"claude","complexity":"medium","reason":"multi-file feature","profile":{"role":"developer","skills":[],"tools":[],"constraints":[]},"selected_skills":[]},"usage":{"input_tokens":100,"output_tokens":50}}"#;
1965
1966 let parsed = router.parse_llm_response(response);
1967 assert!(
1968 parsed.is_ok(),
1969 "should handle result-as-object: {}",
1970 parsed.unwrap_err()
1971 );
1972 assert_eq!(parsed.unwrap().executor, "claude");
1973 }
1974
1975 #[serial(cooldown_state)]
1977 #[test]
1978 fn parse_llm_response_agent_instead_of_executor() {
1979 let config = RouterConfig::default();
1980 let router = Router::new(config);
1981
1982 let response = r#"{"agent":"claude","complexity":"medium","reason":"multi-file feature","profile":{"role":"developer","skills":[],"tools":[],"constraints":[]},"selected_skills":[]}"#;
1983
1984 let parsed = router.parse_llm_response(response);
1985 assert!(
1986 parsed.is_ok(),
1987 "should accept 'agent' alias: {}",
1988 parsed.unwrap_err()
1989 );
1990 assert_eq!(parsed.unwrap().executor, "claude");
1991 }
1992
1993 #[serial(cooldown_state)]
1995 #[test]
1996 fn parse_llm_response_minimal_json() {
1997 let config = RouterConfig::default();
1998 let router = Router::new(config);
1999
2000 let response = r#"{"executor":"codex","complexity":"simple"}"#;
2001
2002 let parsed = router.parse_llm_response(response);
2003 assert!(
2004 parsed.is_ok(),
2005 "should accept minimal JSON: {}",
2006 parsed.unwrap_err()
2007 );
2008 assert_eq!(parsed.unwrap().executor, "codex");
2009 }
2010
2011 #[serial(cooldown_state)]
2015 #[test]
2016 fn fixture_route_response_string() {
2017 let config = RouterConfig::default();
2018 let router = Router::new(config);
2019
2020 let response = include_str!("../../../tests/fixtures/route-response-string.json");
2021 let parsed = router.parse_llm_response(response).unwrap();
2022 assert_eq!(parsed.executor, "codex");
2023 assert_eq!(parsed.complexity, "medium");
2024 assert!(!parsed.reason.is_empty());
2025 assert!(!parsed.profile.role.is_empty());
2026 }
2027
2028 #[serial(cooldown_state)]
2031 #[test]
2032 fn fixture_route_response_object() {
2033 let config = RouterConfig::default();
2034 let router = Router::new(config);
2035
2036 let response = include_str!("../../../tests/fixtures/route-response-object.json");
2037 let parsed = router.parse_llm_response(response).unwrap();
2038 assert_eq!(parsed.executor, "claude");
2039 assert_eq!(parsed.complexity, "complex");
2040 }
2041
2042 #[serial(cooldown_state)]
2044 #[test]
2045 fn fixture_route_response_markdown() {
2046 let config = RouterConfig::default();
2047 let router = Router::new(config);
2048
2049 let response = include_str!("../../../tests/fixtures/route-response-markdown.json");
2050 let parsed = router.parse_llm_response(response).unwrap();
2051 assert_eq!(parsed.executor, "codex");
2052 assert_eq!(parsed.complexity, "medium");
2053 }
2054
2055 #[serial(cooldown_state)]
2057 #[test]
2058 fn parse_llm_response_claude_envelope_with_prose() {
2059 let config = RouterConfig::default();
2060 let router = Router::new(config);
2061
2062 let inner = "Based on the task description, here's my routing decision:\n\n```json\n{\"executor\":\"codex\",\"complexity\":\"medium\",\"reason\":\"standard feature work\",\"profile\":{\"role\":\"developer\",\"skills\":[\"rust\"],\"tools\":[\"git\",\"cargo\"],\"constraints\":[]},\"selected_skills\":[]}\n```\n\nThis task involves adding slash commands which is standard development work.";
2063 let envelope = serde_json::json!({
2064 "type": "result",
2065 "subtype": "success",
2066 "is_error": false,
2067 "result": inner,
2068 "usage": {"input_tokens": 100, "output_tokens": 80}
2069 });
2070
2071 let parsed = router.parse_llm_response(&envelope.to_string());
2072 assert!(
2073 parsed.is_ok(),
2074 "should handle prose + code block in envelope: {}",
2075 parsed.unwrap_err()
2076 );
2077 assert_eq!(parsed.unwrap().executor, "codex");
2078 }
2079
2080 #[serial(cooldown_state)]
2081 #[tokio::test]
2082 async fn route_round_robin_basic() {
2083 crate::engine::cooldown::reset_global_state().await;
2084 let config = RouterConfig {
2087 mode: "round_robin".to_string(),
2088 ..Default::default()
2089 };
2090
2091 let agents = vec!["claude".to_string(), "codex".to_string()];
2093 let mut weights = AgentWeights::default();
2094 weights.ensure_agents(&agents);
2095 let router = Router {
2096 config,
2097 available_agents: agents,
2098 weights,
2099 llm_router: std::sync::Arc::new(LlmRouter::new()),
2100 ollama_router: None,
2101 rr_index: 0,
2102 last_agent: None,
2103 review_rr_index: 0,
2104 router_pool: vec![],
2105 pool_index: 0,
2106 };
2107
2108 let task = create_test_task("1", "Test task", vec![]);
2109 let result =
2110 strategies::route_via_round_robin(&router.available_agents, &router.config, &task)
2111 .unwrap();
2112
2113 assert_eq!(result.agent, "codex");
2115 assert_eq!(result.reason, "round_robin (task 1 % 2 agents)");
2116 }
2117
2118 #[serial(cooldown_state)]
2119 #[tokio::test]
2120 async fn route_uses_label_override() {
2121 crate::engine::cooldown::reset_global_state().await;
2122 let mut config = RouterConfig::default();
2123 config
2126 .model_map
2127 .entry("medium".to_string())
2128 .or_default()
2129 .insert("claude".to_string(), vec!["haiku".to_string()]);
2130 let agents = vec!["claude".to_string(), "codex".to_string()];
2131 let mut weights = AgentWeights::default();
2132 weights.ensure_agents(&agents);
2133 let mut router = Router {
2134 config,
2135 available_agents: agents,
2136 weights,
2137 llm_router: std::sync::Arc::new(LlmRouter::new()),
2138 ollama_router: None,
2139 rr_index: 0,
2140 last_agent: None,
2141 review_rr_index: 0,
2142 router_pool: vec![],
2143 pool_index: 0,
2144 };
2145
2146 let task = create_test_task("1", "Test", vec!["agent:claude".to_string()]);
2147 let store = test_store().await;
2148
2149 let result = router.route(&task, &store, "test/repo").await.unwrap();
2151 assert_eq!(result.agent, "claude");
2152 assert!(result.reason.contains("label"));
2153 }
2154
2155 #[serial(cooldown_state)]
2156 #[test]
2157 fn check_routing_sanity_warnings() {
2158 let config = RouterConfig::default();
2159 let router = Router::new(config);
2160
2161 let task = create_test_task("1", "Fix API", vec!["backend".to_string()]);
2163 let profile = AgentProfile {
2164 role: "general".to_string(),
2165 skills: vec!["api".to_string()],
2166 tools: vec![],
2167 constraints: vec![],
2168 };
2169 let warning = router.check_routing_sanity(&task, "claude", &profile);
2170 assert!(warning.is_some());
2171 assert!(warning.unwrap().contains("backend"));
2172
2173 let task = create_test_task("1", "Update README", vec!["docs".to_string()]);
2175 let warning = router.check_routing_sanity(&task, "codex", &profile);
2176 assert!(warning.is_some());
2177 assert!(warning.unwrap().contains("docs"));
2178
2179 let task = create_test_task("1", "Fix bug", vec!["bug".to_string()]);
2181 let warning = router.check_routing_sanity(&task, "codex", &profile);
2182 assert!(warning.is_none());
2183 }
2184
2185 #[serial(cooldown_state)]
2186 #[tokio::test]
2187 async fn router_reload_preserves_structure() {
2188 crate::engine::cooldown::reset_global_state().await;
2189 let config = RouterConfig::default();
2190 let agents = vec!["claude".to_string()];
2191 let mut weights = AgentWeights::default();
2192 weights.ensure_agents(&agents);
2193 let mut router = Router {
2194 config,
2195 available_agents: agents,
2196 weights,
2197 llm_router: std::sync::Arc::new(LlmRouter::new()),
2198 ollama_router: None,
2199 rr_index: 0,
2200 last_agent: None,
2201 review_rr_index: 0,
2202 router_pool: vec![],
2203 pool_index: 0,
2204 };
2205
2206 router.reload_async().await;
2208
2209 assert!(
2211 router.config.mode == "llm"
2212 || router.config.mode == "round_robin"
2213 || router.config.mode == "local",
2214 "mode should be 'llm', 'local', or 'round_robin', got '{}'",
2215 router.config.mode
2216 );
2217 assert!(!router.config.fallback_executor.is_empty());
2219 assert!(!router.config.allowed_tools.is_empty());
2221 }
2222
2223 #[serial(cooldown_state)]
2226 #[test]
2227 fn rate_limit_state_defaults_to_full_weight() {
2228 let state = RateLimitState::default();
2229 assert_eq!(state.weight, DEFAULT_WEIGHT);
2230 assert_eq!(state.consecutive_hits, 0);
2231 assert!(state.last_limited_at.is_none());
2232 assert!(!state.is_limited());
2233 }
2234
2235 #[serial(cooldown_state)]
2236 #[test]
2237 fn rate_limit_state_decays_on_hit() {
2238 let mut state = RateLimitState::default();
2239 state.record_rate_limit();
2240
2241 assert!(state.weight < DEFAULT_WEIGHT);
2242 assert_eq!(state.consecutive_hits, 1);
2243 assert!(state.last_limited_at.is_some());
2244 assert!(state.is_limited());
2245
2246 let expected = DEFAULT_WEIGHT * RATE_LIMIT_DECAY;
2248 assert!((state.weight - expected).abs() < 1e-10);
2249 }
2250
2251 #[serial(cooldown_state)]
2252 #[test]
2253 fn rate_limit_state_never_drops_below_min() {
2254 let mut state = RateLimitState::default();
2255
2256 for _ in 0..100 {
2258 state.record_rate_limit();
2259 }
2260
2261 assert!(state.weight >= MIN_WEIGHT);
2262 assert_eq!(state.consecutive_hits, 100);
2263 }
2264
2265 #[serial(cooldown_state)]
2266 #[test]
2267 fn rate_limit_state_recovers_on_success() {
2268 let mut state = RateLimitState::default();
2269
2270 state.record_rate_limit();
2272 state.record_rate_limit();
2273 let after_decay = state.weight;
2274
2275 state.record_success();
2277 assert!(state.weight > after_decay);
2278 assert_eq!(state.consecutive_hits, 0);
2279 }
2280
2281 #[serial(cooldown_state)]
2282 #[test]
2283 fn rate_limit_state_success_caps_at_default() {
2284 let mut state = RateLimitState::default();
2285
2286 state.record_success();
2288 assert_eq!(state.weight, DEFAULT_WEIGHT);
2289 }
2290
2291 #[serial(cooldown_state)]
2292 #[test]
2293 fn agent_weights_ensure_agents() {
2294 let mut weights = AgentWeights::default();
2295 let agents = vec!["claude".to_string(), "codex".to_string()];
2296 weights.ensure_agents(&agents);
2297
2298 assert_eq!(weights.states.len(), 2);
2299 assert_eq!(weights.get_weight("claude"), DEFAULT_WEIGHT);
2300 assert_eq!(weights.get_weight("codex"), DEFAULT_WEIGHT);
2301 }
2302
2303 #[serial(cooldown_state)]
2304 #[test]
2305 fn agent_weights_ensure_agents_preserves_existing() {
2306 let mut weights = AgentWeights::default();
2307 let agents = vec!["claude".to_string(), "codex".to_string()];
2308 weights.ensure_agents(&agents);
2309
2310 weights.record_rate_limit("claude");
2312 let claude_weight = weights.get_weight("claude");
2313 assert!(claude_weight < DEFAULT_WEIGHT);
2314
2315 let agents2 = vec![
2317 "claude".to_string(),
2318 "codex".to_string(),
2319 "opencode".to_string(),
2320 ];
2321 weights.ensure_agents(&agents2);
2322
2323 assert_eq!(weights.get_weight("claude"), claude_weight);
2324 assert_eq!(weights.get_weight("opencode"), DEFAULT_WEIGHT);
2325 }
2326
2327 #[test]
2328 fn default_missing_weight_uses_minimum_of_configured() {
2329 use super::weights::default_missing_weight;
2330
2331 let mut configured = std::collections::HashMap::new();
2332 configured.insert("claude".to_string(), 0.2);
2333 configured.insert("codex".to_string(), 0.2);
2334 configured.insert("opencode".to_string(), 0.2);
2335 configured.insert("kimi".to_string(), 0.2);
2336
2337 assert_eq!(default_missing_weight(&configured), 0.2);
2340 }
2341
2342 #[test]
2343 fn default_missing_weight_falls_back_when_none_configured() {
2344 use super::weights::default_missing_weight;
2345
2346 let configured = std::collections::HashMap::new();
2347 assert_eq!(default_missing_weight(&configured), DEFAULT_WEIGHT);
2348 }
2349
2350 #[serial(cooldown_state)]
2351 #[test]
2352 fn ensure_agents_with_weights_missing_agent_does_not_outrank_configured() {
2353 let mut weights = AgentWeights::default();
2354 let mut configured = std::collections::HashMap::new();
2355 configured.insert("claude".to_string(), 0.2);
2356 configured.insert("codex".to_string(), 0.2);
2357 configured.insert("opencode".to_string(), 0.2);
2358 configured.insert("kimi".to_string(), 0.2);
2359
2360 let agents = vec![
2361 "claude".to_string(),
2362 "codex".to_string(),
2363 "opencode".to_string(),
2364 "kimi".to_string(),
2365 "minimax".to_string(),
2366 ];
2367 weights.ensure_agents_with_weights(&agents, &configured);
2368
2369 assert_eq!(weights.get_weight("minimax"), 0.2);
2372 assert_eq!(weights.get_weight("claude"), 0.2);
2373 }
2374
2375 #[serial(cooldown_state)]
2376 #[test]
2377 fn agent_weights_record_rate_limit() {
2378 let mut weights = AgentWeights::default();
2379 weights.ensure_agents(&["claude".to_string(), "codex".to_string()]);
2380
2381 weights.record_rate_limit("claude");
2382
2383 assert!(weights.get_weight("claude") < DEFAULT_WEIGHT);
2384 assert_eq!(weights.get_weight("codex"), DEFAULT_WEIGHT);
2385 }
2386
2387 #[serial(cooldown_state)]
2388 #[test]
2389 fn agent_weights_record_success() {
2390 let mut weights = AgentWeights::default();
2391 weights.ensure_agents(&["claude".to_string()]);
2392
2393 weights.record_rate_limit("claude");
2394 let after_limit = weights.get_weight("claude");
2395
2396 weights.record_success("claude");
2397 assert!(weights.get_weight("claude") > after_limit);
2398 }
2399
2400 #[serial(cooldown_state)]
2401 #[test]
2402 fn agent_weights_snapshot() {
2403 let mut weights = AgentWeights::default();
2404 weights.ensure_agents(&["claude".to_string(), "codex".to_string()]);
2405 weights.record_rate_limit("claude");
2406
2407 let snap = weights.snapshot();
2408 assert_eq!(snap.len(), 2);
2409
2410 assert_eq!(snap[0].0, "claude");
2412 assert!(snap[0].1 < DEFAULT_WEIGHT);
2413 assert_eq!(snap[0].2, 1); assert_eq!(snap[1].0, "codex");
2416 assert_eq!(snap[1].1, DEFAULT_WEIGHT);
2417 assert_eq!(snap[1].2, 0); }
2419
2420 #[serial(cooldown_state)]
2421 #[test]
2422 fn agent_weights_weighted_select_favors_higher_weight() {
2423 let mut weights = AgentWeights::default();
2424 let agents = vec!["claude".to_string(), "codex".to_string()];
2425 weights.ensure_agents(&agents);
2426
2427 for _ in 0..10 {
2429 weights.record_rate_limit("claude");
2430 }
2431
2432 let mut claude_count = 0;
2434 let mut codex_count = 0;
2435 for _ in 0..100 {
2436 match weights.weighted_select(&agents, "task-1") {
2437 Some(ref a) if a == "claude" => claude_count += 1,
2438 Some(ref a) if a == "codex" => codex_count += 1,
2439 _ => {}
2440 }
2441 }
2442
2443 assert!(
2446 codex_count > claude_count,
2447 "codex ({codex_count}) should get more selections than claude ({claude_count})"
2448 );
2449 }
2450
2451 #[serial(cooldown_state)]
2452 #[test]
2453 fn agent_weights_weighted_select_empty_returns_none() {
2454 let weights = AgentWeights::default();
2455 assert!(weights.weighted_select(&[], "task-1").is_none());
2456 }
2457
2458 #[serial(cooldown_state)]
2459 #[test]
2460 fn agent_weights_batch_routing_produces_varied_selections() {
2461 let mut weights = AgentWeights::default();
2464 let agents = vec!["claude".to_string(), "codex".to_string()];
2465 weights.ensure_agents(&agents);
2466
2467 let task_ids: Vec<String> = (1..=20).map(|i| format!("task-{i}")).collect();
2469 let selections: Vec<String> = task_ids
2470 .iter()
2471 .filter_map(|id| weights.weighted_select(&agents, id))
2472 .collect();
2473
2474 let claude_count = selections.iter().filter(|a| a.as_str() == "claude").count();
2477 let codex_count = selections.iter().filter(|a| a.as_str() == "codex").count();
2478 assert!(
2479 claude_count > 0 && codex_count > 0,
2480 "batch routing should distribute across agents; got claude={claude_count} codex={codex_count}"
2481 );
2482 }
2483
2484 #[serial(cooldown_state)]
2485 #[test]
2486 fn agent_weights_weighted_select_single_agent() {
2487 let mut weights = AgentWeights::default();
2488 let agents = vec!["claude".to_string()];
2489 weights.ensure_agents(&agents);
2490
2491 let selected = weights.weighted_select(&agents, "task-1");
2492 assert_eq!(selected, Some("claude".to_string()));
2493 }
2494
2495 #[serial(cooldown_state)]
2496 #[test]
2497 fn agent_weights_tick_recovery_restores_weight() {
2498 let mut weights = AgentWeights::default();
2499 weights.ensure_agents(&["claude".to_string()]);
2500
2501 let state = weights.states.get_mut("claude").unwrap();
2503 state.weight = 0.5;
2504 state.last_limited_at = Some(Instant::now() - RECOVERY_DELAY - Duration::from_secs(1));
2505 state.consecutive_hits = 2;
2506
2507 let before = weights.get_weight("claude");
2508 weights.tick_recovery();
2509 let after = weights.get_weight("claude");
2510
2511 assert!(after > before, "weight should increase after recovery tick");
2512 }
2513
2514 #[serial(cooldown_state)]
2515 #[test]
2516 fn agent_weights_tick_recovery_clears_on_full_restore() {
2517 let mut weights = AgentWeights::default();
2518 weights.ensure_agents(&["claude".to_string()]);
2519
2520 let state = weights.states.get_mut("claude").unwrap();
2522 state.weight = DEFAULT_WEIGHT - 0.01;
2523 state.last_limited_at = Some(Instant::now() - RECOVERY_DELAY - Duration::from_secs(1));
2524 state.consecutive_hits = 1;
2525
2526 weights.tick_recovery();
2527
2528 let state = weights.states.get("claude").unwrap();
2529 assert_eq!(state.weight, DEFAULT_WEIGHT);
2530 assert!(state.last_limited_at.is_none());
2531 assert_eq!(state.consecutive_hits, 0);
2532 }
2533
2534 #[serial(cooldown_state)]
2535 #[tokio::test]
2536 async fn route_weighted_round_robin_basic() {
2537 crate::engine::cooldown::reset_global_state().await;
2538 let mut config = RouterConfig {
2539 weighted_round_robin: true,
2540 ..Default::default()
2541 };
2542 config
2544 .model_map
2545 .entry("medium".to_string())
2546 .or_default()
2547 .insert("codex".to_string(), vec!["gpt-5.2".to_string()]);
2548
2549 let agents = vec!["claude".to_string(), "codex".to_string()];
2550 let mut weights = AgentWeights::default();
2551 weights.ensure_agents(&agents);
2552 let mut router = Router {
2553 config,
2554 available_agents: agents,
2555 weights,
2556 llm_router: std::sync::Arc::new(LlmRouter::new()),
2557 ollama_router: None,
2558 rr_index: 0,
2559 last_agent: None,
2560 review_rr_index: 0,
2561 router_pool: vec![],
2562 pool_index: 0,
2563 };
2564
2565 let task = create_test_task("1", "Test task", vec![]);
2566 let store = test_store().await;
2567 let result = router.route(&task, &store, "test/repo").await.unwrap();
2568
2569 assert!(result.reason.contains("weighted_round_robin"));
2571 assert!(
2572 result.agent == "claude" || result.agent == "codex",
2573 "agent should be claude or codex, got '{}'",
2574 result.agent
2575 );
2576 }
2577
2578 #[serial(cooldown_state)]
2579 #[tokio::test]
2580 async fn route_weighted_round_robin_respects_label_override() {
2581 crate::engine::cooldown::reset_global_state().await;
2582 let mut config = RouterConfig {
2583 weighted_round_robin: true,
2584 ..Default::default()
2585 };
2586 config
2588 .model_map
2589 .entry("medium".to_string())
2590 .or_default()
2591 .insert("codex".to_string(), vec!["gpt-5.2".to_string()]);
2592
2593 let agents = vec!["claude".to_string(), "codex".to_string()];
2594 let mut weights = AgentWeights::default();
2595 weights.ensure_agents(&agents);
2596 let mut router = Router {
2597 config,
2598 available_agents: agents,
2599 weights,
2600 llm_router: std::sync::Arc::new(LlmRouter::new()),
2601 ollama_router: None,
2602 rr_index: 0,
2603 last_agent: None,
2604 review_rr_index: 0,
2605 router_pool: vec![],
2606 pool_index: 0,
2607 };
2608
2609 let task = create_test_task("1", "Test task", vec!["agent:codex".to_string()]);
2611 let store = test_store().await;
2612 let result = router.route(&task, &store, "test/repo").await.unwrap();
2613 assert_eq!(result.agent, "codex");
2614 assert!(result.reason.contains("label"));
2615 }
2616
2617 #[serial(cooldown_state)]
2618 #[test]
2619 fn router_config_weighted_round_robin_default_false() {
2620 let config = RouterConfig::default();
2621 assert!(!config.weighted_round_robin);
2622 }
2623
2624 #[serial(cooldown_state)]
2625 #[test]
2626 fn router_record_and_recover_weights() {
2627 let config = RouterConfig {
2628 weighted_round_robin: true,
2629 ..Default::default()
2630 };
2631 let mut router = Router::new(config);
2632 router.available_agents = vec!["claude".to_string(), "codex".to_string()];
2634 router.weights.ensure_agents(&router.available_agents);
2635
2636 router.record_rate_limit("claude");
2638 assert!(router.weights.get_weight("claude") < DEFAULT_WEIGHT);
2639 assert_eq!(router.weights.get_weight("codex"), DEFAULT_WEIGHT);
2640
2641 router.record_success("claude");
2643 let after_success = router.weights.get_weight("claude");
2644 assert!(after_success > MIN_WEIGHT);
2645 }
2646
2647 #[serial(cooldown_state)]
2648 #[test]
2649 fn review_rr_index_advances() {
2650 let mut config = RouterConfig::default();
2651 for agent in &["test_a", "test_b", "test_c"] {
2653 config
2654 .model_map
2655 .entry("medium".to_string())
2656 .or_default()
2657 .insert((*agent).to_string(), vec!["test-model".to_string()]);
2658 }
2659 let agents = vec![
2660 "test_a".to_string(),
2661 "test_b".to_string(),
2662 "test_c".to_string(),
2663 ];
2664 let mut weights = AgentWeights::default();
2665 weights.ensure_agents(&agents);
2666 let mut router = Router {
2667 config,
2668 available_agents: agents,
2669 weights,
2670 llm_router: std::sync::Arc::new(LlmRouter::new()),
2671 ollama_router: None,
2672 rr_index: 0,
2673 last_agent: None,
2674 review_rr_index: 0,
2675 router_pool: vec![],
2676 pool_index: 0,
2677 };
2678
2679 let a1 = router.next_round_robin_agent(&[], "medium").unwrap();
2680 let a2 = router.next_round_robin_agent(&[], "medium").unwrap();
2681 let a3 = router.next_round_robin_agent(&[], "medium").unwrap();
2682
2683 let mut seen = vec![a1, a2, a3];
2685 seen.sort();
2686 assert_eq!(seen, vec!["test_a", "test_b", "test_c"]);
2687 }
2688
2689 #[serial(cooldown_state)]
2690 #[test]
2691 fn review_rr_fallback_with_cooled_agent() {
2692 let mut config = RouterConfig::default();
2696 for agent in &["test_a", "test_b", "test_c"] {
2698 config
2699 .model_map
2700 .entry("medium".to_string())
2701 .or_default()
2702 .insert((*agent).to_string(), vec!["test-model".to_string()]);
2703 }
2704 let agents = vec!["test_a".to_string(), "test_b".to_string()];
2706 let mut weights = AgentWeights::default();
2707 weights.ensure_agents(&agents);
2708 let mut router = Router {
2709 config,
2710 available_agents: agents,
2711 weights,
2712 llm_router: std::sync::Arc::new(LlmRouter::new()),
2713 ollama_router: None,
2714 rr_index: 0,
2715 last_agent: None,
2716 review_rr_index: 2,
2718 router_pool: vec![],
2719 pool_index: 0,
2720 };
2721
2722 let result = router.next_round_robin_agent(&["test_a", "test_b"], "medium");
2724 assert!(
2725 result.is_some(),
2726 "next_round_robin_agent must not return None when candidates is non-empty"
2727 );
2728 }
2729
2730 #[serial(cooldown_state)]
2731 #[test]
2732 fn review_rr_index_uses_candidates_len_not_available_len() {
2733 let mut config = RouterConfig::default();
2738 for agent in &["test_a", "test_b"] {
2741 config
2742 .model_map
2743 .entry("medium".to_string())
2744 .or_default()
2745 .insert((*agent).to_string(), vec!["test-model".to_string()]);
2746 }
2747 let agents = vec![
2748 "test_a".to_string(),
2749 "test_b".to_string(),
2750 "test_c".to_string(), ];
2752 let mut weights = AgentWeights::default();
2753 weights.ensure_agents(&agents);
2754 let mut router = Router {
2755 config,
2756 available_agents: agents,
2757 weights,
2758 llm_router: std::sync::Arc::new(LlmRouter::new()),
2759 ollama_router: None,
2760 rr_index: 0,
2761 last_agent: None,
2762 review_rr_index: 0,
2763 router_pool: vec![],
2764 pool_index: 0,
2765 };
2766
2767 let r1 = router.next_round_robin_agent(&[], "medium").unwrap();
2776 assert_eq!(
2777 router.review_rr_index, 1,
2778 "index should be 1 after first call"
2779 );
2780
2781 let r2 = router.next_round_robin_agent(&[], "medium").unwrap();
2782 assert_eq!(
2783 router.review_rr_index, 0,
2784 "index should wrap to 0 after second call"
2785 );
2786
2787 let r3 = router.next_round_robin_agent(&[], "medium").unwrap();
2788 assert_eq!(
2789 router.review_rr_index, 1,
2790 "index should be 1 after third call"
2791 );
2792
2793 assert_ne!(r1, r2, "consecutive calls should select different agents");
2795 assert_eq!(r1, r3, "third call should match first (clean cycle)");
2796 }
2797
2798 #[serial(cooldown_state)]
2799 #[tokio::test]
2800 async fn last_agent_tracks_routing() {
2801 crate::engine::cooldown::reset_global_state().await;
2802 let mut config = RouterConfig {
2803 mode: "round_robin".to_string(),
2804 ..Default::default()
2805 };
2806 config
2807 .model_map
2808 .entry("medium".to_string())
2809 .or_default()
2810 .insert("test_x".to_string(), vec!["test-model-x".to_string()]);
2811 config
2812 .model_map
2813 .entry("medium".to_string())
2814 .or_default()
2815 .insert("test_y".to_string(), vec!["test-model-y".to_string()]);
2816 let agents = vec!["test_x".to_string(), "test_y".to_string()];
2817 let mut weights = AgentWeights::default();
2818 weights.ensure_agents(&agents);
2819 let mut router = Router {
2820 config,
2821 available_agents: agents,
2822 weights,
2823 llm_router: std::sync::Arc::new(LlmRouter::new()),
2824 ollama_router: None,
2825 rr_index: 0,
2826 last_agent: None,
2827 review_rr_index: 0,
2828 router_pool: vec![],
2829 pool_index: 0,
2830 };
2831
2832 assert!(router.last_agent.is_none());
2833
2834 let task = create_test_task("1", "Test routing", vec![]);
2835 let store = test_store().await;
2836 let result = router.route(&task, &store, "test/repo").await.unwrap();
2837
2838 assert!(router.last_agent.is_some());
2839 assert_eq!(router.last_agent.as_deref(), Some(result.agent.as_str()));
2840 }
2841
2842 #[serial(cooldown_state)]
2845 #[test]
2846 fn parse_pool_entry_splits_on_first_colon() {
2847 let (agent, model) = super::parse_pool_entry("claude:haiku");
2848 assert_eq!(agent, "claude");
2849 assert_eq!(model, "haiku");
2850 }
2851
2852 #[serial(cooldown_state)]
2853 #[test]
2854 fn parse_pool_entry_model_with_slash() {
2855 let (agent, model) = super::parse_pool_entry("opencode:github-copilot/gpt-5-mini");
2857 assert_eq!(agent, "opencode");
2858 assert_eq!(model, "github-copilot/gpt-5-mini");
2859 }
2860
2861 #[serial(cooldown_state)]
2862 #[test]
2863 fn parse_pool_entry_no_colon() {
2864 let (agent, model) = super::parse_pool_entry("claude");
2865 assert_eq!(agent, "claude");
2866 assert_eq!(model, "");
2867 }
2868
2869 #[serial(cooldown_state)]
2870 #[test]
2871 fn effective_pool_uses_configured_pool() {
2872 let config = RouterConfig {
2873 pool: vec!["kimi:k2p5".to_string(), "claude:haiku".to_string()],
2874 ..RouterConfig::default()
2875 };
2876 let pool = config.effective_pool();
2877 assert_eq!(pool, vec!["kimi:k2p5", "claude:haiku"]);
2878 }
2879
2880 #[serial(cooldown_state)]
2881 #[test]
2882 fn effective_pool_falls_back_to_router_agent_model() {
2883 let config = RouterConfig::default();
2884 let pool = config.effective_pool();
2886 assert_eq!(pool.len(), 1);
2887 assert!(pool[0].starts_with(&config.router_agent));
2888 assert!(pool[0].contains(&config.router_model));
2889 }
2890
2891 #[serial(cooldown_state)]
2892 #[test]
2893 fn effective_fallback_uses_configured_fallback() {
2894 let config = RouterConfig {
2895 fallback: "claude:haiku".to_string(),
2896 ..RouterConfig::default()
2897 };
2898 assert_eq!(config.effective_fallback(), "claude:haiku");
2899 }
2900
2901 #[serial(cooldown_state)]
2902 #[test]
2903 fn effective_fallback_derives_from_router_agent_model() {
2904 let config = RouterConfig::default();
2905 let fallback = config.effective_fallback();
2906 assert!(fallback.starts_with(&config.router_agent));
2907 assert!(fallback.contains(&config.router_model));
2908 }
2909
2910 #[serial(cooldown_state)]
2913 #[test]
2914 fn expand_pool_basic_entries() {
2915 let config = RouterConfig {
2916 pool: vec!["claude:haiku".to_string(), "kimi:k2p5".to_string()],
2917 ..RouterConfig::default()
2918 };
2919 let expanded = Router::expand_pool(&config);
2920 assert_eq!(expanded.len(), 2);
2921 assert_eq!(expanded[0], ("claude".to_string(), "haiku".to_string()));
2922 assert_eq!(expanded[1], ("kimi".to_string(), "k2p5".to_string()));
2923 }
2924
2925 #[serial(cooldown_state)]
2926 #[test]
2927 fn expand_pool_uses_effective_pool_when_pool_is_empty() {
2928 let config = RouterConfig {
2929 pool: vec![],
2930 router_agent: "claude".to_string(),
2931 router_model: "haiku".to_string(),
2932 ..RouterConfig::default()
2933 };
2934 let expanded = Router::expand_pool(&config);
2935 assert_eq!(expanded.len(), 1);
2936 assert_eq!(expanded[0], ("claude".to_string(), "haiku".to_string()));
2937 }
2938
2939 #[serial(cooldown_state)]
2940 #[test]
2941 fn expand_pool_opencode_free_skipped_when_not_installed() {
2942 if crate::cmd_cache::command_exists("opencode") {
2946 return;
2948 }
2949 let config = RouterConfig {
2950 pool: vec!["opencode:free".to_string()],
2951 fallback: "claude:haiku".to_string(),
2952 ..RouterConfig::default()
2953 };
2954 let expanded = Router::expand_pool(&config);
2955 assert_eq!(expanded.len(), 1);
2957 assert_eq!(expanded[0].0, "claude");
2958 assert_eq!(expanded[0].1, "haiku");
2959 }
2960
2961 #[serial(cooldown_state)]
2962 #[test]
2963 fn expand_pool_single_entry_from_default() {
2964 let config = RouterConfig::default();
2965 let expanded = Router::expand_pool(&config);
2966 assert!(
2967 !expanded.is_empty(),
2968 "pool must never be empty after expansion"
2969 );
2970 }
2971
2972 #[serial(cooldown_state)]
2975 #[test]
2976 fn pool_index_initializes_to_zero() {
2977 let config = RouterConfig::default();
2978 let router = Router::new(config);
2979 assert_eq!(router.pool_index, 0);
2980 }
2981
2982 #[serial(cooldown_state)]
2983 #[test]
2984 fn router_pool_non_empty_after_construction() {
2985 let config = RouterConfig::default();
2986 let router = Router::new(config);
2987 assert!(!router.router_pool.is_empty());
2988 }
2989
2990 #[serial(cooldown_state)]
2991 #[tokio::test]
2992 async fn reload_resets_pool_index() {
2993 crate::engine::cooldown::reset_global_state().await;
2994 let config = RouterConfig::default();
2995 let mut router = Router::new(config);
2996 router.pool_index = 5;
2997 router.reload_async().await;
2998 assert_eq!(router.pool_index, 0);
2999 }
3000
3001 #[serial(cooldown_state)]
3002 #[test]
3003 fn pool_index_advances_after_failed_attempt() {
3004 let mut router = Router::new(RouterConfig::default());
3005 router.router_pool = vec![
3006 ("claude".to_string(), "haiku".to_string()),
3007 ("codex".to_string(), "gpt-5.2".to_string()),
3008 (
3009 "opencode".to_string(),
3010 "github-copilot/gpt-5-mini".to_string(),
3011 ),
3012 ];
3013 router.pool_index = 0;
3014
3015 router.advance_pool_index_after_attempt(0, router.router_pool.len());
3016
3017 assert_eq!(router.pool_index, 1);
3018 }
3019
3020 #[serial(cooldown_state)]
3021 #[test]
3022 fn pool_index_wraps_after_last_attempt() {
3023 let mut router = Router::new(RouterConfig::default());
3024 router.router_pool = vec![
3025 ("claude".to_string(), "haiku".to_string()),
3026 ("codex".to_string(), "gpt-5.2".to_string()),
3027 (
3028 "opencode".to_string(),
3029 "github-copilot/gpt-5-mini".to_string(),
3030 ),
3031 ];
3032 router.pool_index = 2;
3033
3034 router.advance_pool_index_after_attempt(2, router.router_pool.len());
3035
3036 assert_eq!(router.pool_index, 0);
3037 }
3038
3039 #[serial(cooldown_state)]
3040 #[test]
3041 fn earliest_pool_cooldown_returns_none_when_no_cooldowns() {
3042 let router = Router::new(RouterConfig::default());
3043 assert!(router.earliest_pool_cooldown().is_none());
3044 }
3045
3046 #[serial(cooldown_state)]
3047 #[tokio::test]
3048 async fn earliest_pool_cooldown_returns_earliest_when_set() {
3049 crate::engine::cooldown::reset_global_state().await;
3050 let config = RouterConfig {
3052 pool: vec![
3053 "claude:haiku-4-5-20251001".to_string(),
3054 "opencode:haiku-4-5-20251001".to_string(),
3055 ],
3056 ..Default::default()
3057 };
3058 let router = Router::new(config);
3059 set_model_cooldown("claude", "haiku-4-5-20251001", 300, "silence_detected").await; set_model_cooldown("opencode", "haiku-4-5-20251001", 600, "silence_detected").await; let earliest = router.earliest_pool_cooldown();
3063 assert!(earliest.is_some());
3064 let now = chrono::Utc::now().timestamp();
3065 let remaining = earliest.unwrap() - now;
3066 assert!((295..=300).contains(&remaining));
3068 }
3069
3070 #[serial(cooldown_state)]
3071 #[tokio::test]
3072 async fn earliest_pool_cooldown_skips_non_pool_entries() {
3073 crate::engine::cooldown::reset_global_state().await;
3074 let config = RouterConfig {
3076 pool: vec![
3077 "claude:pool-model-a".to_string(),
3078 "opencode:pool-model-b".to_string(),
3079 ],
3080 ..Default::default()
3081 };
3082 let router = Router::new(config);
3083 set_model_cooldown("claude", "opus", 999, "silence_detected").await;
3085 set_model_cooldown("opencode", "pool-model-b", 300, "silence_detected").await;
3087
3088 let earliest = router.earliest_pool_cooldown();
3089 assert!(earliest.is_some());
3090 let now = chrono::Utc::now().timestamp();
3091 let remaining = earliest.unwrap() - now;
3092 assert!((295..=300).contains(&remaining));
3094 }
3095
3096 #[serial(cooldown_state)]
3099 #[test]
3100 fn degraded_agent_excluded_from_routing() {
3101 let mut config = RouterConfig::default();
3102 config
3103 .model_map
3104 .entry("medium".to_string())
3105 .or_default()
3106 .insert(
3107 "test_degraded_routing".to_string(),
3108 vec!["model-a".to_string()],
3109 );
3110
3111 let mut router = Router::new(config);
3112 router.available_agents = vec!["test_degraded_routing".to_string()];
3113
3114 assert!(router.agent_is_routable("test_degraded_routing", "medium"));
3116
3117 mark_agent_degraded("test_degraded_routing");
3119
3120 assert!(!router.agent_is_routable("test_degraded_routing", "medium"));
3122
3123 assert!(
3125 router.available_agents_for_complexity("medium").is_empty(),
3126 "degraded agent should be excluded from available agents"
3127 );
3128
3129 assert_eq!(router.healthy_agent_count("medium"), 0);
3131
3132 clear_agent_degraded("test_degraded_routing");
3134 }
3135
3136 #[serial(cooldown_state)]
3137 #[test]
3138 fn healthy_agents_skips_degraded_but_keeps_healthy() {
3139 let mut config = RouterConfig::default();
3140 for agent in &["agent_healthy", "agent_degraded"] {
3141 config
3142 .model_map
3143 .entry("medium".to_string())
3144 .or_default()
3145 .insert(agent.to_string(), vec!["model-x".to_string()]);
3146 }
3147
3148 let mut router = Router::new(config);
3149 router.available_agents = vec!["agent_healthy".to_string(), "agent_degraded".to_string()];
3150
3151 mark_agent_degraded("agent_degraded");
3152
3153 let candidates = router.available_agents_for_complexity("medium");
3154 assert_eq!(candidates, vec!["agent_healthy".to_string()]);
3155 assert_eq!(router.healthy_agent_count("medium"), 1);
3156
3157 clear_agent_degraded("agent_degraded");
3159 }
3160
3161 #[serial(cooldown_state)]
3162 #[tokio::test]
3163 async fn refresh_health_marks_degraded_from_rate_limits() {
3164 crate::engine::cooldown::reset_global_state().await;
3165 let store = test_store().await;
3166 let agent = "test_refresh_health_agent";
3167
3168 let mut config = RouterConfig::default();
3169 config
3170 .model_map
3171 .entry("medium".to_string())
3172 .or_default()
3173 .insert(agent.to_string(), vec!["model-z".to_string()]);
3174
3175 let mut router = Router::new(config);
3176 router.available_agents = vec![agent.to_string()];
3177
3178 for _ in 0..4 {
3180 store
3181 .record_rate_limit(agent, "rate_limit", None)
3182 .await
3183 .unwrap();
3184 }
3185
3186 router.refresh_health(&store).await;
3187
3188 assert!(
3189 is_agent_degraded(agent),
3190 "agent should be degraded after exceeding rate limit threshold"
3191 );
3192 assert!(
3193 !router.agent_is_routable(agent, "medium"),
3194 "degraded agent should not be routable"
3195 );
3196
3197 clear_agent_degraded(agent);
3199 }
3200
3201 #[serial(cooldown_state)]
3204 #[tokio::test]
3205 async fn agent_has_any_available_model_false_when_only_model_cooled() {
3206 crate::engine::cooldown::reset_global_state().await;
3207 let mut config = RouterConfig::default();
3208 for tier in ["simple", "medium", "complex", "review"] {
3210 config
3211 .model_map
3212 .entry(tier.to_string())
3213 .or_default()
3214 .insert(
3215 "test_all_cooled".to_string(),
3216 vec!["only-model".to_string()],
3217 );
3218 }
3219
3220 let mut router = Router::new(config);
3221 router.available_agents = vec!["test_all_cooled".to_string()];
3222
3223 assert!(router.agent_has_any_available_model("test_all_cooled"));
3224
3225 record_model_failure("test_all_cooled", "only-model").await;
3226 assert!(is_model_in_cooldown("test_all_cooled", "only-model"));
3227
3228 assert!(
3229 !router.agent_has_any_available_model("test_all_cooled"),
3230 "agent with its only model cooled across every tier should have no available model"
3231 );
3232 }
3233
3234 #[serial(cooldown_state)]
3235 #[tokio::test]
3236 async fn llm_candidate_excludes_agent_with_all_models_cooled() {
3237 crate::engine::cooldown::reset_global_state().await;
3241 let mut config = RouterConfig::default();
3242 for tier in ["simple", "medium", "complex", "review"] {
3243 config
3244 .model_map
3245 .entry(tier.to_string())
3246 .or_default()
3247 .insert("agent_cooled_model".to_string(), vec!["opus".to_string()]);
3248 config
3249 .model_map
3250 .get_mut(tier)
3251 .unwrap()
3252 .insert("agent_healthy".to_string(), vec!["model-x".to_string()]);
3253 }
3254
3255 let mut router = Router::new(config);
3256 router.available_agents = vec![
3257 "agent_cooled_model".to_string(),
3258 "agent_healthy".to_string(),
3259 ];
3260
3261 record_model_failure("agent_cooled_model", "opus").await;
3262 assert!(is_model_in_cooldown("agent_cooled_model", "opus"));
3263 assert!(!is_agent_in_cooldown("agent_cooled_model"));
3265
3266 let uncooled_agents: Vec<String> = router
3267 .available_agents
3268 .iter()
3269 .filter(|a| {
3270 !is_agent_in_cooldown(a)
3271 && !is_agent_degraded(a)
3272 && router.agent_has_available_model_for_coding_tiers(a)
3273 })
3274 .cloned()
3275 .collect();
3276
3277 assert_eq!(
3278 uncooled_agents,
3279 vec!["agent_healthy".to_string()],
3280 "agent whose only model is cooled must be excluded from the LLM candidate list"
3281 );
3282 }
3283
3284 #[serial(cooldown_state)]
3285 #[tokio::test]
3286 async fn llm_candidate_excludes_agent_with_only_simple_tier_uncooled() {
3287 crate::engine::cooldown::reset_global_state().await;
3294 let mut config = RouterConfig::default();
3295 config
3296 .model_map
3297 .entry("simple".to_string())
3298 .or_default()
3299 .insert("minimax_like".to_string(), vec!["sonnet".to_string()]);
3300 config
3301 .model_map
3302 .entry("medium".to_string())
3303 .or_default()
3304 .insert("minimax_like".to_string(), vec!["opus".to_string()]);
3305 config
3306 .model_map
3307 .entry("complex".to_string())
3308 .or_default()
3309 .insert("minimax_like".to_string(), vec!["opus".to_string()]);
3310 config
3312 .model_map
3313 .entry("review".to_string())
3314 .or_default()
3315 .insert("minimax_like".to_string(), vec!["opus".to_string()]);
3316 for tier in ["simple", "medium", "complex", "review"] {
3317 config
3318 .model_map
3319 .get_mut(tier)
3320 .unwrap()
3321 .insert("agent_healthy".to_string(), vec!["model-x".to_string()]);
3322 }
3323
3324 let mut router = Router::new(config);
3325 router.available_agents = vec!["minimax_like".to_string(), "agent_healthy".to_string()];
3326
3327 record_model_failure("minimax_like", "opus").await;
3328 assert!(is_model_in_cooldown("minimax_like", "opus"));
3329 assert!(!is_agent_in_cooldown("minimax_like"));
3332 assert!(router.agent_has_any_available_model("minimax_like"));
3333 assert!(!router.agent_has_available_model_for_coding_tiers("minimax_like"));
3334
3335 let uncooled_agents: Vec<String> = router
3336 .available_agents
3337 .iter()
3338 .filter(|a| {
3339 !is_agent_in_cooldown(a)
3340 && !is_agent_degraded(a)
3341 && router.agent_has_available_model_for_coding_tiers(a)
3342 })
3343 .cloned()
3344 .collect();
3345
3346 assert_eq!(
3347 uncooled_agents,
3348 vec!["agent_healthy".to_string()],
3349 "agent with only its simple tier uncooled must be excluded from the LLM candidate list"
3350 );
3351 }
3352}