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