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 match store.resolve_id_by_external(repo, &task.id.0).await {
691 Ok(Some(id)) => match store.block_claim_if_waiting(id).await {
692 Ok(Some(blockers)) => {
693 return Err(crate::store::WaitingOnDependency { blockers }.into());
694 }
695 Ok(None) => {}
696 Err(err) => return Err(err),
697 },
698 Ok(None) => {}
699 Err(err) => return Err(err),
700 }
701
702 loop {
703 let store_agent = match store.get_agent_by_external_id(repo, &task.id.0).await {
709 Ok(Some(agent)) => {
710 if !agent.is_empty() && self.config.agents.iter().any(|cfg| cfg == &agent) {
712 Some(agent)
713 } else {
714 None
715 }
716 }
717 Ok(None) => None,
718 Err(e) => {
719 tracing::warn!(
720 repo,
721 task_id = %task.id.0,
722 err = %e,
723 "failed to fetch stored agent, falling back to label/LLM routing"
724 );
725 None
726 }
727 };
728
729 let resolved_agent = store_agent.or_else(|| {
731 strategies::extract_agent_from_labels(&self.config.agents, &task.labels)
732 });
733
734 if let Some(agent) = resolved_agent {
735 let complexity = strategies::extract_complexity_from_labels(&task.labels);
736 if self.agent_is_routable(&agent, &complexity) {
745 let model = self
746 .config
747 .model_for_complexity(&agent, &complexity, &task.id.0);
748
749 if model.is_some() {
757 tracing::debug!(
758 task_id = %task.id.0,
759 agent = %agent,
760 complexity = %complexity,
761 "routed via label"
762 );
763 let mut result = RouteResult::from_config(
764 agent.clone(),
765 complexity.clone(),
766 format!("label agent:{agent}"),
767 &self.config,
768 &task.id.0,
769 );
770 result.profile.role = format!("{} specialist", agent);
773 result.model = model.clone();
774 self.log_route_activity(store, repo, &task.id.0, &result, None)
775 .await;
776 return Ok(result);
777 } else {
778 tracing::warn!(task_id = %task.id.0, agent = %agent, "label agent has no available model — falling through to LLM routing");
783 }
784 }
785
786 let candidates = self.available_agents_for_complexity(&complexity);
787 if candidates.is_empty() {
788 self.wait_for_cooldown(Some(&complexity)).await?;
789 continue;
790 }
791 }
793
794 let complexity = strategies::extract_complexity_from_labels(&task.labels);
795
796 let mut candidates = self.available_agents_for_complexity(&complexity);
798 if candidates.is_empty() {
799 self.wait_for_cooldown(Some(&complexity)).await?;
800 continue;
801 }
802
803 if let Some(filtered) = self
808 .skip_no_code_agent(store, repo, &task.id.0, &candidates)
809 .await
810 {
811 candidates = filtered;
812 }
813
814 if self.config.weighted_round_robin {
816 let result = strategies::route_via_weighted_round_robin(
817 &candidates,
818 &self.weights,
819 &self.config,
820 task,
821 &mut self.last_agent,
822 )?;
823 self.log_route_activity(store, repo, &task.id.0, &result, None)
824 .await;
825 return Ok(result);
826 }
827
828 if self.config.mode == "round_robin" {
830 tracing::debug!(task_id = %task.id.0, "routing via round-robin mode");
831 let result = strategies::route_via_round_robin_stateful(
832 &candidates,
833 &self.config,
834 task,
835 &mut self.rr_index,
836 &mut self.last_agent,
837 )?;
838 self.log_route_activity(store, repo, &task.id.0, &result, None)
839 .await;
840 return Ok(result);
841 }
842
843 if self.config.mode == "local" {
845 tracing::debug!(task_id = %task.id.0, ollama_url = %self.config.ollama_url, "routing via local Ollama");
846 let ollama_router = self
847 .ollama_router
848 .as_ref()
849 .expect("ollama_router must be initialized in Router::new when mode=local");
850 match ollama_router.route(task, &self.config).await {
851 Ok(result) => {
852 self.log_route_activity(store, repo, &task.id.0, &result, None)
853 .await;
854 return Ok(result);
855 }
856 Err(e) => {
857 tracing::warn!(
858 task_id = %task.id.0,
859 error = %e,
860 "Ollama routing failed, falling back to round-robin"
861 );
862 let result = strategies::route_via_round_robin_stateful(
863 &candidates,
864 &self.config,
865 task,
866 &mut self.rr_index,
867 &mut self.last_agent,
868 )?;
869 self.log_route_activity(store, repo, &task.id.0, &result, None)
870 .await;
871 return Ok(result);
872 }
873 }
874 }
875
876 let route_attempts = self.get_route_attempts(&task.id.0, store, repo).await;
878
879 if route_attempts >= self.config.max_route_attempts {
880 tracing::warn!(
881 task_id = %task.id.0,
882 attempts = route_attempts,
883 max = self.config.max_route_attempts,
884 "max LLM route attempts reached, falling back to round-robin"
885 );
886 let candidates = self.available_agents_for_complexity(&complexity);
887 if candidates.is_empty() {
888 self.wait_for_cooldown(Some(&complexity)).await?;
889 continue;
890 }
891 let result = strategies::route_via_round_robin_stateful(
892 &candidates,
893 &self.config,
894 task,
895 &mut self.rr_index,
896 &mut self.last_agent,
897 )?;
898 self.log_route_activity(store, repo, &task.id.0, &result, None)
899 .await;
900 return Ok(result);
901 }
902
903 tracing::debug!(task_id = %task.id.0, "starting LLM routing");
905
906 match self.route_with_llm(task, repo).await {
907 Ok(result) => {
908 let candidates = self.available_agents_for_complexity(&result.complexity);
909 if candidates.is_empty() {
910 self.wait_for_cooldown(Some(&result.complexity)).await?;
911 continue;
912 }
913
914 let no_code_agent =
917 get_task_field_direct(store, repo, &task.id.0, "no_code_last_agent")
918 .await
919 .unwrap_or_else(|| {
920 tracing::warn!(task_id = %task.id.0, "no_code_last_agent DB read returned None — loop detection may be impaired");
921 String::new()
922 });
923 if result.agent == no_code_agent {
924 let fallback_agent = candidates
926 .iter()
927 .find(|a| a.as_str() != no_code_agent)
928 .cloned()
929 .unwrap_or_else(|| candidates[0].clone());
930 if fallback_agent.as_str() == no_code_agent {
931 tracing::warn!(
932 task_id = %task.id.0,
933 no_code_agent = %no_code_agent,
934 complexity = %result.complexity,
935 "all available agents are the no-code agent — waiting for another agent"
936 );
937 self.wait_for_cooldown(Some(&result.complexity)).await?;
938 continue;
939 }
940 tracing::info!(
941 task_id = %task.id.0,
942 llm_selected = %result.agent,
943 skipped = %no_code_agent,
944 fallback = %fallback_agent,
945 "LLM selected the no-code agent — routing to alternative"
946 );
947 let fallback_model = self.config.model_for_complexity(
948 &fallback_agent,
949 &result.complexity,
950 &task.id.0,
951 );
952
953 self.set_route_attempts(&task.id.0, 0, store, repo).await;
954 let result = RouteResult {
955 agent: fallback_agent.clone(),
956 model: fallback_model,
957 complexity: result.complexity.clone(),
958 estimate: result.estimate,
959 reason: format!(
960 "LLM selected no-code agent {}; routed to {}",
961 result.agent, fallback_agent
962 ),
963 profile: result.profile.clone(),
964 selected_skills: result.selected_skills.clone(),
965 warning: Some(format!(
966 "LLM selected no-code agent {}; used fallback {}",
967 result.agent, fallback_agent
968 )),
969 };
970 self.log_route_activity(store, repo, &task.id.0, &result, None)
971 .await;
972 return Ok(result);
973 }
974
975 let model_cooled = result
976 .model
977 .as_deref()
978 .is_some_and(|model| is_model_in_cooldown(&result.agent, model));
979 if !candidates.contains(&result.agent) || model_cooled {
980 let fallback_agent = candidates
981 .iter()
982 .find(|agent| *agent != &result.agent)
983 .cloned()
984 .unwrap_or_else(|| candidates[0].clone());
985 let fallback_model = self.config.model_for_complexity(
986 &fallback_agent,
987 &result.complexity,
988 &task.id.0,
989 );
990
991 if fallback_model.is_none() && 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(s) are cooled for the only available agent; waiting for cooldown"
1002 );
1003 self.wait_for_cooldown(Some(&result.complexity)).await?;
1004 continue;
1005 }
1006
1007 if fallback_agent == result.agent {
1011 tracing::warn!(
1012 task_id = %task.id.0,
1013 agent = %result.agent,
1014 complexity = %result.complexity,
1015 "LLM-selected model was cooled; selecting an alternative model for the same agent"
1016 );
1017 } else {
1018 tracing::warn!(
1019 task_id = %task.id.0,
1020 agent = %result.agent,
1021 fallback = %fallback_agent,
1022 complexity = %result.complexity,
1023 "LLM selected cooled agent/model; rerouting to available agent"
1024 );
1025 }
1026
1027 self.set_route_attempts(&task.id.0, 0, store, repo).await;
1029
1030 let reason = if fallback_agent == result.agent {
1031 format!(
1032 "LLM selected cooled model; using same agent {} with different model",
1033 fallback_agent
1034 )
1035 } else {
1036 format!(
1037 "LLM selected cooled agent/model; rerouted to {}",
1038 fallback_agent
1039 )
1040 };
1041
1042 let result = RouteResult {
1043 agent: fallback_agent.clone(),
1044 model: fallback_model,
1045 complexity: result.complexity.clone(),
1046 estimate: result.estimate,
1047 reason: reason.clone(),
1048 profile: result.profile.clone(),
1049 selected_skills: result.selected_skills.clone(),
1050 warning: Some(if fallback_agent == result.agent {
1051 "LLM-selected model was cooled; selected alternative model for same agent"
1052 .to_string()
1053 } else {
1054 "LLM-selected agent/model was cooled; rerouted to available agent"
1055 .to_string()
1056 }),
1057 };
1058 self.log_route_activity(store, repo, &task.id.0, &result, None)
1059 .await;
1060 return Ok(result);
1061 }
1062
1063 self.set_route_attempts(&task.id.0, 0, store, repo).await;
1065 tracing::info!(
1066 task_id = %task.id.0,
1067 agent = %result.agent,
1068 complexity = %result.complexity,
1069 "routed via LLM"
1070 );
1071 self.log_route_activity(store, repo, &task.id.0, &result, None)
1072 .await;
1073 return Ok(result);
1074 }
1075 Err(e) => {
1076 if let Some(err) = e.downcast_ref::<AllAgentsCooledError>() {
1077 let scope = err.scope();
1078 tracing::warn!(scope = %scope, "router cooldown gate tripped");
1079 let scope_opt = match scope {
1083 "all agents" | "router pool" => None,
1084 _ => Some(scope),
1085 };
1086 self.wait_for_cooldown(scope_opt).await?;
1087 continue;
1088 }
1089
1090 let new_attempts = route_attempts + 1;
1091 self.set_route_attempts(&task.id.0, new_attempts, store, repo)
1092 .await;
1093 tracing::warn!(
1094 task_id = %task.id.0,
1095 error = %e,
1096 error_chain = ?e,
1097 attempt = new_attempts,
1098 max = self.config.max_route_attempts,
1099 "LLM routing failed"
1100 );
1101
1102 if new_attempts >= self.config.max_route_attempts {
1103 tracing::info!(
1104 task_id = %task.id.0,
1105 "falling back to round-robin after {} failed attempts",
1106 new_attempts
1107 );
1108 let candidates = self.available_agents_for_complexity(&complexity);
1109 if candidates.is_empty() {
1110 self.wait_for_cooldown(Some(&complexity)).await?;
1111 continue;
1112 }
1113 let result = strategies::route_via_round_robin_stateful(
1114 &candidates,
1115 &self.config,
1116 task,
1117 &mut self.rr_index,
1118 &mut self.last_agent,
1119 )?;
1120 self.log_route_activity(store, repo, &task.id.0, &result, None)
1121 .await;
1122 return Ok(result);
1123 }
1124
1125 let candidates = self.available_agents_for_complexity(&complexity);
1126 if candidates.is_empty() {
1127 self.wait_for_cooldown(Some(&complexity)).await?;
1128 continue;
1129 }
1130 let result = strategies::route_via_fallback(
1131 &candidates,
1132 &self.config,
1133 task,
1134 Some(&self.weights),
1135 )?;
1136 self.log_route_activity(store, repo, &task.id.0, &result, None)
1137 .await;
1138 return Ok(result);
1139 }
1140 }
1141 }
1142 }
1143
1144 async fn get_route_attempts(
1146 &self,
1147 task_id: &str,
1148 store: &std::sync::Arc<TaskStore>,
1149 repo: &str,
1150 ) -> u32 {
1151 match store.resolve_task_id(repo, task_id).await {
1152 Ok(Some(store_id)) => store
1153 .get(store_id)
1154 .await
1155 .map(|t| t.route_attempts as u32)
1156 .unwrap_or(0),
1157 _ => 0,
1158 }
1159 }
1160
1161 async fn set_route_attempts(
1163 &self,
1164 task_id: &str,
1165 attempts: u32,
1166 store: &std::sync::Arc<TaskStore>,
1167 repo: &str,
1168 ) {
1169 if let Ok(Some(store_id)) = store.resolve_task_id(repo, task_id).await {
1170 if let Err(e) = store
1171 .set_fields(store_id, &[("route_attempts", serde_json::json!(attempts))])
1172 .await
1173 {
1174 tracing::warn!(task_id, error = %e, "failed to store route_attempts");
1175 }
1176 }
1177 }
1178
1179 async fn log_route_activity(
1181 &self,
1182 store: &std::sync::Arc<TaskStore>,
1183 repo: &str,
1184 task_id: &str,
1185 result: &RouteResult,
1186 from_agent: Option<&str>,
1187 ) {
1188 let details = serde_json::json!({
1189 "reason": result.reason,
1190 "complexity": result.complexity,
1191 "skills": result.selected_skills,
1192 "role": result.profile.role,
1193 "warning": result.warning,
1194 });
1195 let event_type = if from_agent.is_some() {
1196 "rerouted"
1197 } else {
1198 "routed"
1199 };
1200 store_log_activity(
1201 &Some(Arc::clone(store)),
1202 repo,
1203 task_id,
1204 event_type,
1205 None,
1206 Some("routed"),
1207 Some(&result.agent),
1208 result.model.as_deref(),
1209 Some(&details),
1210 )
1211 .await;
1212 }
1213
1214 async fn route_with_llm(
1220 &mut self,
1221 task: &ExternalTask,
1222 repo: &str,
1223 ) -> anyhow::Result<RouteResult> {
1224 let uncooled_agents: Vec<String> = self
1234 .available_agents
1235 .iter()
1236 .filter(|a| {
1237 !is_agent_in_cooldown(a)
1238 && !is_agent_degraded(a)
1239 && self.agent_has_available_model_for_coding_tiers(a)
1240 })
1241 .cloned()
1242 .collect();
1243 if uncooled_agents.is_empty() {
1244 return Err(AllAgentsCooledError::new("all agents".to_string(), None).into());
1245 }
1246 let llm_agents = uncooled_agents;
1247
1248 let pool = self.router_pool.clone();
1249 let n = pool.len();
1250 let start = self.pool_index;
1251 let mut last_err: Option<anyhow::Error> = None;
1252 let mut attempted_any = false;
1253
1254 for i in 0..n {
1256 let idx = (start + i) % n;
1257 let (agent, model) = &pool[idx];
1258 let model_str = model.as_str();
1259
1260 if crate::engine::runner::response::is_model_in_cooldown(agent, model_str) {
1261 tracing::debug!(agent, model = model_str, "pool entry on cooldown, skipping");
1262 continue;
1263 }
1264
1265 attempted_any = true;
1267
1268 let model_opt = if model_str.is_empty() {
1269 None
1270 } else {
1271 Some(model_str)
1272 };
1273
1274 match self
1275 .llm_router
1276 .route_with_llm_using(
1277 task,
1278 &llm_agents,
1279 &self.config,
1280 &mut self.last_agent,
1281 repo,
1282 agent,
1283 model_opt,
1284 )
1285 .await
1286 {
1287 Ok(result) => {
1288 self.advance_pool_index_after_attempt(idx, n);
1290 tracing::debug!(
1291 agent,
1292 model = model_str,
1293 pool_idx = idx,
1294 "router LLM pool entry succeeded"
1295 );
1296 record_agent_success(agent, model_str).await;
1297 return Ok(result);
1298 }
1299 Err(e) => {
1300 let err_msg = e.to_string();
1301 let is_timeout = err_msg.contains(TIMEOUT_PREFIX);
1302 if is_timeout {
1303 self.advance_pool_index_after_attempt(idx, n);
1313 tracing::warn!(
1314 agent,
1315 model = model_str,
1316 next_pool_idx = self.pool_index,
1317 "router LLM pool entry timed out — recording cooldown, will retry next tick"
1318 );
1319 crate::engine::runner::response::record_model_failure(agent, model_str)
1320 .await;
1321 return Err(e);
1322 } else {
1323 tracing::warn!(
1324 agent,
1325 model = model_str,
1326 error = %e,
1327 "pool entry failed, recording cooldown and trying next"
1328 );
1329 crate::engine::runner::response::record_model_failure(agent, model_str)
1330 .await;
1331 }
1332 last_err = Some(e);
1333 self.advance_pool_index_after_attempt(idx, n);
1334 }
1335 }
1336 }
1337
1338 if !attempted_any {
1343 let now = chrono::Utc::now().timestamp();
1344 let earliest = self.earliest_pool_cooldown();
1345 if let Some(until) = earliest {
1346 let remaining = until.saturating_sub(now);
1348 tracing::warn!(
1349 remaining_secs = remaining,
1350 pool = ?pool,
1351 "all router LLM pool entries on cooldown — will try fallback"
1352 );
1353 } else {
1355 return Err(last_err
1358 .unwrap_or_else(|| anyhow::anyhow!("all router LLM pool entries exhausted")));
1359 }
1360 }
1361
1362 let fallback = self.config.effective_fallback();
1364 let (fb_agent, fb_model) = parse_pool_entry(&fallback);
1365 let fb_model_str = fb_model.as_str();
1366
1367 let fallback_already_tried = pool.iter().any(|(a, m)| a == &fb_agent && m == &fb_model);
1369
1370 if !fallback_already_tried
1371 && !crate::engine::runner::response::is_model_in_cooldown(&fb_agent, fb_model_str)
1372 {
1373 tracing::info!(
1374 agent = %fb_agent,
1375 model = %fb_model_str,
1376 "all pool entries exhausted — trying fallback router LLM"
1377 );
1378 let fb_model_opt = if fb_model_str.is_empty() {
1379 None
1380 } else {
1381 Some(fb_model_str)
1382 };
1383 match self
1384 .llm_router
1385 .route_with_llm_using(
1386 task,
1387 &llm_agents,
1388 &self.config,
1389 &mut self.last_agent,
1390 repo,
1391 &fb_agent,
1392 fb_model_opt,
1393 )
1394 .await
1395 {
1396 Ok(result) => {
1397 record_agent_success(&fb_agent, fb_model_str).await;
1398 return Ok(result);
1399 }
1400 Err(e) => {
1401 let err_msg = e.to_string();
1402 let is_timeout = err_msg.contains(TIMEOUT_PREFIX);
1403 if is_timeout {
1404 crate::engine::runner::response::record_model_failure(
1407 &fb_agent,
1408 fb_model_str,
1409 )
1410 .await;
1411 tracing::warn!(
1412 agent = %fb_agent,
1413 model = %fb_model_str,
1414 "fallback router LLM timed out — returning immediately to unblock tick loop"
1415 );
1416 return Err(e);
1417 } else {
1418 tracing::warn!(
1419 agent = %fb_agent,
1420 model = %fb_model_str,
1421 error = %e,
1422 "fallback router LLM also failed"
1423 );
1424 crate::engine::runner::response::record_model_failure(
1425 &fb_agent,
1426 fb_model_str,
1427 )
1428 .await;
1429 }
1430 last_err = Some(e);
1431 }
1432 }
1433 }
1434
1435 Err(last_err.unwrap_or_else(|| anyhow::anyhow!("all router LLM pool entries exhausted")))
1436 }
1437
1438 pub fn record_rate_limit(&mut self, agent: &str) {
1442 self.weights.record_rate_limit(agent);
1443 }
1444
1445 pub fn record_success(&mut self, agent: &str) {
1447 self.weights.record_success(agent);
1448 }
1449
1450 pub fn tick_weight_recovery(&mut self) {
1455 self.weights.tick_recovery();
1456 }
1457
1458 pub async fn store_route_result(
1460 &self,
1461 task_id: &str,
1462 result: &RouteResult,
1463 store: &std::sync::Arc<TaskStore>,
1464 repo: &str,
1465 ) -> anyhow::Result<()> {
1466 if let Ok(Some(store_id)) = store.resolve_task_id(repo, task_id).await {
1467 store
1468 .set_fields(
1469 store_id,
1470 &[
1471 ("agent", serde_json::json!(result.agent)),
1472 ("complexity", serde_json::json!(result.complexity)),
1473 ("route_reason", serde_json::json!(result.reason)),
1474 (
1475 "model",
1476 serde_json::json!(result.model.as_deref().unwrap_or("")),
1477 ),
1478 ],
1479 )
1480 .await?;
1481 }
1482 Ok(())
1483 }
1484}
1485
1486pub async fn get_route_result(
1488 store: &std::sync::Arc<TaskStore>,
1489 repo: &str,
1490 task_id: &str,
1491) -> Result<RouteResult, RouteResultError> {
1492 let store_id = store
1493 .resolve_task_id(repo, task_id)
1494 .await
1495 .map_err(RouteResultError::Other)?
1496 .ok_or_else(|| {
1497 RouteResultError::Other(anyhow::anyhow!("task {task_id} not found in store"))
1498 })?;
1499 let task = store.get(store_id).await.map_err(RouteResultError::Other)?;
1500
1501 let agent = task
1502 .agent
1503 .clone()
1504 .filter(|a| !a.is_empty())
1505 .ok_or_else(|| RouteResultError::NoAgent {
1506 task_id: task_id.to_string(),
1507 })?;
1508
1509 let complexity = if task.complexity.is_empty() {
1510 "medium".to_string()
1511 } else {
1512 task.complexity.clone()
1513 };
1514
1515 let reason = task.route_reason.clone();
1516 let model = task.model.clone().filter(|m| !m.is_empty());
1517
1518 let config = RouterConfig::from_config();
1522 let model = model.filter(|m| {
1523 let complexities = ["simple", "medium", "complex", "review"];
1524 let all_pools: Vec<Vec<String>> = complexities
1526 .iter()
1527 .filter_map(|comp| config.model_pool_for_complexity(&agent, comp))
1528 .collect();
1529 if all_pools.is_empty() {
1531 return true;
1532 }
1533 let valid = all_pools.iter().any(|pool| pool.contains(m));
1534 if !valid {
1535 tracing::warn!(
1536 task_id,
1537 agent,
1538 model = %m,
1539 "discarding stale model: not in agent's model pools"
1540 );
1541 }
1542 valid
1543 });
1544
1545 let original_model = task.model.clone().filter(|m| !m.is_empty());
1549 if original_model.is_some() && model.is_none() {
1550 let stale_model = original_model.as_deref().unwrap_or("unknown");
1551 let stale_err = format!("stale model discarded: {stale_model} not in {agent} model pools");
1552 if let Err(e) = store
1553 .set_fields(
1554 store_id,
1555 &[
1556 ("model", serde_json::json!("")),
1557 ("last_error", serde_json::json!(stale_err)),
1558 ],
1559 )
1560 .await
1561 {
1562 tracing::warn!(task_id = %task_id, error = %e, "failed to clear stale model from store — will retry on next dispatch");
1564 }
1565 }
1566
1567 Ok(RouteResult {
1568 agent,
1569 model,
1570 complexity,
1571 estimate: 0,
1572 reason,
1573 profile: AgentProfile::default(),
1574 selected_skills: vec![],
1575 warning: None,
1576 })
1577}
1578
1579#[cfg(test)]
1580mod tests {
1581 use super::config::DEFAULT_AGENTS;
1582 use super::llm::LlmRouteResponse;
1583 use super::strategies;
1584 use super::weights::{
1585 AgentWeights, RateLimitState, DEFAULT_WEIGHT, MIN_WEIGHT, RATE_LIMIT_DECAY, RECOVERY_DELAY,
1586 };
1587 use super::*;
1588 use crate::backends::{ExternalId, ExternalTask};
1589 use crate::engine::cooldown::{
1590 clear_agent_degraded, is_agent_degraded, is_model_in_cooldown, mark_agent_degraded,
1591 record_model_failure, set_model_cooldown,
1592 };
1593 use crate::store::TaskStore;
1594 use serial_test::serial;
1595 use std::time::{Duration, Instant};
1596
1597 impl Router {
1600 fn parse_llm_response(&self, response: &str) -> anyhow::Result<LlmRouteResponse> {
1601 self.llm_router.parse_llm_response("claude", response)
1602 }
1603
1604 fn check_routing_sanity(
1605 &self,
1606 task: &ExternalTask,
1607 agent: &str,
1608 profile: &AgentProfile,
1609 ) -> Option<String> {
1610 self.llm_router.check_routing_sanity(task, agent, profile)
1611 }
1612 }
1613
1614 async fn test_store() -> std::sync::Arc<TaskStore> {
1615 std::sync::Arc::new(TaskStore::open_memory().await.unwrap())
1616 }
1617
1618 fn create_test_task(id: &str, title: &str, labels: Vec<String>) -> ExternalTask {
1619 ExternalTask {
1620 id: ExternalId(id.to_string()),
1621 title: title.to_string(),
1622 body: "Test body".to_string(),
1623 state: "open".to_string(),
1624 labels,
1625 author: "test".to_string(),
1626 created_at: "2026-01-01T00:00:00Z".to_string(),
1627 updated_at: "2026-01-01T00:00:00Z".to_string(),
1628 url: format!("https://github.com/test/test/issues/{id}"),
1629 }
1630 }
1631
1632 #[serial(cooldown_state)]
1633 #[test]
1634 fn extract_agent_from_labels() {
1635 let mut config = RouterConfig::default();
1636 config
1639 .model_map
1640 .entry("medium".to_string())
1641 .or_default()
1642 .insert("claude".to_string(), vec!["haiku".to_string()]);
1643
1644 assert_eq!(
1645 strategies::extract_agent_from_labels(&config.agents, &["agent:claude".to_string()]),
1646 Some("claude".to_string())
1647 );
1648 assert_eq!(
1649 strategies::extract_agent_from_labels(&config.agents, &["agent:codex".to_string()]),
1650 Some("codex".to_string())
1651 );
1652 assert_eq!(
1653 strategies::extract_agent_from_labels(&config.agents, &["agent:opencode".to_string()]),
1654 Some("opencode".to_string())
1655 );
1656 assert_eq!(
1657 strategies::extract_agent_from_labels(&config.agents, &["status:new".to_string()]),
1658 None
1659 );
1660 assert_eq!(
1662 strategies::extract_agent_from_labels(&config.agents, &["agent:kimi".to_string()]),
1663 Some("kimi".to_string())
1664 );
1665 assert_eq!(
1666 strategies::extract_agent_from_labels(&config.agents, &["agent:minimax".to_string()]),
1667 Some("minimax".to_string())
1668 );
1669 }
1670
1671 #[serial(cooldown_state)]
1672 #[test]
1673 fn default_agents_constant() {
1674 assert_eq!(DEFAULT_AGENTS.len(), 7);
1675 assert!(DEFAULT_AGENTS.contains(&"claude"));
1676 assert!(DEFAULT_AGENTS.contains(&"kimi"));
1677 assert!(DEFAULT_AGENTS.contains(&"minimax"));
1678 assert!(DEFAULT_AGENTS.contains(&"glm"));
1679 assert!(DEFAULT_AGENTS.contains(&"grok"));
1680 }
1681
1682 #[serial(cooldown_state)]
1683 #[test]
1684 fn extract_complexity_from_labels() {
1685 assert_eq!(
1686 strategies::extract_complexity_from_labels(&["complexity:simple".to_string()]),
1687 "simple"
1688 );
1689 assert_eq!(
1690 strategies::extract_complexity_from_labels(&["complexity:medium".to_string()]),
1691 "medium"
1692 );
1693 assert_eq!(
1694 strategies::extract_complexity_from_labels(&["complexity:complex".to_string()]),
1695 "complex"
1696 );
1697 assert_eq!(
1698 strategies::extract_complexity_from_labels(&["status:new".to_string()]),
1699 "medium"
1700 );
1701 }
1702
1703 #[serial(cooldown_state)]
1704 #[test]
1705 fn route_result_serialization() {
1706 let result = RouteResult {
1707 agent: "claude".to_string(),
1708 model: Some("sonnet".to_string()),
1709 complexity: "medium".to_string(),
1710 estimate: 5,
1711 reason: "test".to_string(),
1712 profile: AgentProfile {
1713 role: "backend".to_string(),
1714 skills: vec!["rust".to_string()],
1715 tools: vec!["git".to_string()],
1716 constraints: vec![],
1717 },
1718 selected_skills: vec!["gh".to_string()],
1719 warning: None,
1720 };
1721
1722 let json = serde_json::to_string(&result).unwrap();
1723 assert!(json.contains("claude"));
1724 assert!(json.contains("sonnet"));
1725
1726 let deserialized: RouteResult = serde_json::from_str(&json).unwrap();
1727 assert_eq!(deserialized.agent, "claude");
1728 assert_eq!(deserialized.model, Some("sonnet".to_string()));
1729 }
1730
1731 #[serial(cooldown_state)]
1732 #[test]
1733 fn router_config_default() {
1734 let mut config = RouterConfig::default();
1735 config
1738 .model_map
1739 .entry("medium".to_string())
1740 .or_default()
1741 .insert("claude".to_string(), vec!["haiku".to_string()]);
1742
1743 assert_eq!(config.mode, "round_robin");
1744 assert_eq!(config.router_agent, "claude");
1745 assert_eq!(config.router_model, "haiku");
1746 assert_eq!(config.fallback_executor, "codex");
1747 assert_eq!(config.max_route_attempts, 3);
1748 assert!(!config.allowed_tools.is_empty());
1749 assert!(!config.default_skills.is_empty());
1750
1751 assert_eq!(config.agents.len(), 7);
1753 assert!(config.agents.contains(&"claude".to_string()));
1754 assert!(config.agents.contains(&"codex".to_string()));
1755 assert!(config.agents.contains(&"opencode".to_string()));
1756 assert!(config.agents.contains(&"kimi".to_string()));
1757 assert!(config.agents.contains(&"minimax".to_string()));
1758 assert!(config.agents.contains(&"glm".to_string()));
1759 assert!(config.agents.contains(&"grok".to_string()));
1760 }
1761
1762 #[serial(cooldown_state)]
1763 #[test]
1764 fn model_map_lookup_returns_none_without_config() {
1765 let config = RouterConfig::default();
1767 assert!(config
1768 .model_for_complexity("claude", "simple", "")
1769 .is_none());
1770 assert!(config
1771 .model_for_complexity("claude", "medium", "")
1772 .is_none());
1773 assert!(config
1774 .model_for_complexity("claude", "complex", "")
1775 .is_none());
1776 assert!(config
1777 .model_for_complexity("opencode", "review", "")
1778 .is_none());
1779 }
1780
1781 #[serial(cooldown_state)]
1782 #[test]
1783 fn model_map_lookup_returns_configured_value() {
1784 let mut config = RouterConfig::default();
1786 config
1787 .model_map
1788 .entry("simple".to_string())
1789 .or_default()
1790 .insert("claude".to_string(), vec!["haiku".to_string()]);
1791 assert_eq!(
1792 config.model_for_complexity("claude", "simple", ""),
1793 Some("haiku".to_string())
1794 );
1795 }
1796
1797 #[serial(cooldown_state)]
1798 #[tokio::test]
1799 async fn model_pool_selection_skips_cooled() {
1800 crate::engine::cooldown::reset_global_state().await;
1801 use std::collections::HashMap;
1802
1803 let mut config = RouterConfig::default();
1804 config
1806 .model_map
1807 .entry("simple".to_string())
1808 .or_default()
1809 .insert(
1810 "opencode".to_string(),
1811 vec!["model-a".to_string(), "model-b".to_string()],
1812 );
1813
1814 let m = config.model_for_complexity("opencode", "simple", "task-1");
1816 assert!(m == Some("model-a".to_string()) || m == Some("model-b".to_string()));
1817
1818 record_model_failure("opencode", "model-a").await;
1820 assert!(is_model_in_cooldown("opencode", "model-a"));
1821
1822 for i in 0..20 {
1825 let result = config.model_for_complexity("opencode", "simple", &i.to_string());
1826 assert_eq!(
1827 result,
1828 Some("model-b".to_string()),
1829 "expected model-b when model-a is cooled, got {result:?} for task_id={i}"
1830 );
1831 }
1832
1833 record_model_failure("opencode", "model-b").await;
1835 let fallback = config.model_for_complexity("opencode", "simple", "task-fallback");
1837 assert_eq!(fallback, None);
1838 let _ = HashMap::<(), ()>::new(); }
1840
1841 #[serial(cooldown_state)]
1842 #[test]
1843 fn model_pool_single_string_backward_compat() {
1844 let mut config = RouterConfig::default();
1847 config
1848 .model_map
1849 .entry("simple".to_string())
1850 .or_default()
1851 .insert("claude".to_string(), vec!["haiku".to_string()]);
1852 let m = config.model_for_complexity("claude", "simple", "any-task");
1853 assert_eq!(m, Some("haiku".to_string()));
1854 }
1855
1856 #[serial(cooldown_state)]
1857 #[test]
1858 fn parse_llm_response_direct_json() {
1859 let config = RouterConfig::default();
1860 let router = Router::new(config);
1861
1862 let response = r#"{
1863 "executor": "claude",
1864 "complexity": "complex",
1865 "reason": "requires architecture analysis",
1866 "profile": {
1867 "role": "architect",
1868 "skills": ["rust", "design"],
1869 "tools": ["git", "rg"],
1870 "constraints": []
1871 },
1872 "selected_skills": ["gh"]
1873 }"#;
1874
1875 let parsed = router.parse_llm_response(response).unwrap();
1876 assert_eq!(parsed.executor, "claude");
1877 assert_eq!(parsed.complexity, "complex");
1878 assert_eq!(parsed.reason, "requires architecture analysis");
1879 assert_eq!(parsed.profile.role, "architect");
1880 }
1881
1882 #[serial(cooldown_state)]
1883 #[test]
1884 fn parse_llm_response_markdown_fenced() {
1885 let config = RouterConfig::default();
1886 let router = Router::new(config);
1887
1888 let response = r#"Here's my analysis:
1889
1890```json
1891{
1892 "executor": "codex",
1893 "complexity": "medium",
1894 "reason": "coding task",
1895 "profile": {
1896 "role": "developer",
1897 "skills": ["coding"],
1898 "tools": [],
1899 "constraints": []
1900 },
1901 "selected_skills": []
1902}
1903```
1904
1905Hope that helps!"#;
1906
1907 let parsed = router.parse_llm_response(response).unwrap();
1908 assert_eq!(parsed.executor, "codex");
1909 assert_eq!(parsed.complexity, "medium");
1910 }
1911
1912 #[serial(cooldown_state)]
1913 #[test]
1914 fn parse_llm_response_claude_envelope() {
1915 let config = RouterConfig::default();
1916 let router = Router::new(config);
1917
1918 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}}"#;
1920
1921 let parsed = router.parse_llm_response(response).unwrap();
1922 assert_eq!(parsed.executor, "claude");
1923 assert_eq!(parsed.complexity, "complex");
1924 assert_eq!(parsed.reason, "requires deep analysis");
1925 }
1926
1927 #[serial(cooldown_state)]
1928 #[test]
1929 fn parse_llm_response_claude_envelope_with_code_block() {
1930 let config = RouterConfig::default();
1931 let router = Router::new(config);
1932
1933 let inner = "```json\n{\"executor\":\"codex\",\"complexity\":\"simple\",\"reason\":\"simple fix\",\"profile\":{\"role\":\"developer\",\"skills\":[],\"tools\":[],\"constraints\":[]},\"selected_skills\":[]}\n```";
1935 let envelope = serde_json::json!({
1936 "type": "result",
1937 "subtype": "success",
1938 "is_error": false,
1939 "result": inner,
1940 "usage": {"input_tokens": 50, "output_tokens": 30}
1941 });
1942
1943 let parsed = router.parse_llm_response(&envelope.to_string()).unwrap();
1944 assert_eq!(parsed.executor, "codex");
1945 assert_eq!(parsed.complexity, "simple");
1946 }
1947
1948 #[serial(cooldown_state)]
1949 #[test]
1950 fn parse_llm_response_claude_envelope_error() {
1951 let config = RouterConfig::default();
1952 let router = Router::new(config);
1953
1954 let response = r#"{"type":"result","subtype":"error","is_error":true,"result":"rate limit exceeded","usage":{}}"#;
1955
1956 let err = router.parse_llm_response(response).unwrap_err();
1957 assert!(err.to_string().contains("error"), "got: {err}");
1958 }
1959
1960 #[serial(cooldown_state)]
1961 #[test]
1962 fn parse_llm_response_empty() {
1963 let config = RouterConfig::default();
1964 let router = Router::new(config);
1965
1966 let err = router.parse_llm_response("").unwrap_err();
1967 assert!(err.to_string().contains("empty"), "got: {err}");
1968 }
1969
1970 #[serial(cooldown_state)]
1973 #[test]
1974 fn parse_llm_response_claude_envelope_result_as_object() {
1975 let config = RouterConfig::default();
1976 let router = Router::new(config);
1977
1978 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}}"#;
1979
1980 let parsed = router.parse_llm_response(response);
1981 assert!(
1982 parsed.is_ok(),
1983 "should handle result-as-object: {}",
1984 parsed.unwrap_err()
1985 );
1986 assert_eq!(parsed.unwrap().executor, "claude");
1987 }
1988
1989 #[serial(cooldown_state)]
1991 #[test]
1992 fn parse_llm_response_agent_instead_of_executor() {
1993 let config = RouterConfig::default();
1994 let router = Router::new(config);
1995
1996 let response = r#"{"agent":"claude","complexity":"medium","reason":"multi-file feature","profile":{"role":"developer","skills":[],"tools":[],"constraints":[]},"selected_skills":[]}"#;
1997
1998 let parsed = router.parse_llm_response(response);
1999 assert!(
2000 parsed.is_ok(),
2001 "should accept 'agent' alias: {}",
2002 parsed.unwrap_err()
2003 );
2004 assert_eq!(parsed.unwrap().executor, "claude");
2005 }
2006
2007 #[serial(cooldown_state)]
2009 #[test]
2010 fn parse_llm_response_minimal_json() {
2011 let config = RouterConfig::default();
2012 let router = Router::new(config);
2013
2014 let response = r#"{"executor":"codex","complexity":"simple"}"#;
2015
2016 let parsed = router.parse_llm_response(response);
2017 assert!(
2018 parsed.is_ok(),
2019 "should accept minimal JSON: {}",
2020 parsed.unwrap_err()
2021 );
2022 assert_eq!(parsed.unwrap().executor, "codex");
2023 }
2024
2025 #[serial(cooldown_state)]
2029 #[test]
2030 fn fixture_route_response_string() {
2031 let config = RouterConfig::default();
2032 let router = Router::new(config);
2033
2034 let response = include_str!("../../../tests/fixtures/route-response-string.json");
2035 let parsed = router.parse_llm_response(response).unwrap();
2036 assert_eq!(parsed.executor, "codex");
2037 assert_eq!(parsed.complexity, "medium");
2038 assert!(!parsed.reason.is_empty());
2039 assert!(!parsed.profile.role.is_empty());
2040 }
2041
2042 #[serial(cooldown_state)]
2045 #[test]
2046 fn fixture_route_response_object() {
2047 let config = RouterConfig::default();
2048 let router = Router::new(config);
2049
2050 let response = include_str!("../../../tests/fixtures/route-response-object.json");
2051 let parsed = router.parse_llm_response(response).unwrap();
2052 assert_eq!(parsed.executor, "claude");
2053 assert_eq!(parsed.complexity, "complex");
2054 }
2055
2056 #[serial(cooldown_state)]
2058 #[test]
2059 fn fixture_route_response_markdown() {
2060 let config = RouterConfig::default();
2061 let router = Router::new(config);
2062
2063 let response = include_str!("../../../tests/fixtures/route-response-markdown.json");
2064 let parsed = router.parse_llm_response(response).unwrap();
2065 assert_eq!(parsed.executor, "codex");
2066 assert_eq!(parsed.complexity, "medium");
2067 }
2068
2069 #[serial(cooldown_state)]
2071 #[test]
2072 fn parse_llm_response_claude_envelope_with_prose() {
2073 let config = RouterConfig::default();
2074 let router = Router::new(config);
2075
2076 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.";
2077 let envelope = serde_json::json!({
2078 "type": "result",
2079 "subtype": "success",
2080 "is_error": false,
2081 "result": inner,
2082 "usage": {"input_tokens": 100, "output_tokens": 80}
2083 });
2084
2085 let parsed = router.parse_llm_response(&envelope.to_string());
2086 assert!(
2087 parsed.is_ok(),
2088 "should handle prose + code block in envelope: {}",
2089 parsed.unwrap_err()
2090 );
2091 assert_eq!(parsed.unwrap().executor, "codex");
2092 }
2093
2094 #[serial(cooldown_state)]
2095 #[tokio::test]
2096 async fn route_round_robin_basic() {
2097 crate::engine::cooldown::reset_global_state().await;
2098 let config = RouterConfig {
2101 mode: "round_robin".to_string(),
2102 ..Default::default()
2103 };
2104
2105 let agents = vec!["claude".to_string(), "codex".to_string()];
2107 let mut weights = AgentWeights::default();
2108 weights.ensure_agents(&agents);
2109 let router = Router {
2110 config,
2111 available_agents: agents,
2112 weights,
2113 llm_router: std::sync::Arc::new(LlmRouter::new()),
2114 ollama_router: None,
2115 rr_index: 0,
2116 last_agent: None,
2117 review_rr_index: 0,
2118 router_pool: vec![],
2119 pool_index: 0,
2120 };
2121
2122 let task = create_test_task("1", "Test task", vec![]);
2123 let result =
2124 strategies::route_via_round_robin(&router.available_agents, &router.config, &task)
2125 .unwrap();
2126
2127 assert_eq!(result.agent, "codex");
2129 assert_eq!(result.reason, "round_robin (task 1 % 2 agents)");
2130 }
2131
2132 #[serial(cooldown_state)]
2133 #[tokio::test]
2134 async fn route_uses_label_override() {
2135 crate::engine::cooldown::reset_global_state().await;
2136 let mut config = RouterConfig::default();
2137 config
2140 .model_map
2141 .entry("medium".to_string())
2142 .or_default()
2143 .insert("claude".to_string(), vec!["haiku".to_string()]);
2144 let agents = vec!["claude".to_string(), "codex".to_string()];
2145 let mut weights = AgentWeights::default();
2146 weights.ensure_agents(&agents);
2147 let mut router = Router {
2148 config,
2149 available_agents: agents,
2150 weights,
2151 llm_router: std::sync::Arc::new(LlmRouter::new()),
2152 ollama_router: None,
2153 rr_index: 0,
2154 last_agent: None,
2155 review_rr_index: 0,
2156 router_pool: vec![],
2157 pool_index: 0,
2158 };
2159
2160 let task = create_test_task("1", "Test", vec!["agent:claude".to_string()]);
2161 let store = test_store().await;
2162
2163 let result = router.route(&task, &store, "test/repo").await.unwrap();
2165 assert_eq!(result.agent, "claude");
2166 assert!(result.reason.contains("label"));
2167 }
2168
2169 #[serial(cooldown_state)]
2170 #[test]
2171 fn check_routing_sanity_warnings() {
2172 let config = RouterConfig::default();
2173 let router = Router::new(config);
2174
2175 let task = create_test_task("1", "Fix API", vec!["backend".to_string()]);
2177 let profile = AgentProfile {
2178 role: "general".to_string(),
2179 skills: vec!["api".to_string()],
2180 tools: vec![],
2181 constraints: vec![],
2182 };
2183 let warning = router.check_routing_sanity(&task, "claude", &profile);
2184 assert!(warning.is_some());
2185 assert!(warning.unwrap().contains("backend"));
2186
2187 let task = create_test_task("1", "Update README", vec!["docs".to_string()]);
2189 let warning = router.check_routing_sanity(&task, "codex", &profile);
2190 assert!(warning.is_some());
2191 assert!(warning.unwrap().contains("docs"));
2192
2193 let task = create_test_task("1", "Fix bug", vec!["bug".to_string()]);
2195 let warning = router.check_routing_sanity(&task, "codex", &profile);
2196 assert!(warning.is_none());
2197 }
2198
2199 #[serial(cooldown_state)]
2200 #[tokio::test]
2201 async fn router_reload_preserves_structure() {
2202 crate::engine::cooldown::reset_global_state().await;
2203 let config = RouterConfig::default();
2204 let agents = vec!["claude".to_string()];
2205 let mut weights = AgentWeights::default();
2206 weights.ensure_agents(&agents);
2207 let mut router = Router {
2208 config,
2209 available_agents: agents,
2210 weights,
2211 llm_router: std::sync::Arc::new(LlmRouter::new()),
2212 ollama_router: None,
2213 rr_index: 0,
2214 last_agent: None,
2215 review_rr_index: 0,
2216 router_pool: vec![],
2217 pool_index: 0,
2218 };
2219
2220 router.reload_async().await;
2222
2223 assert!(
2225 router.config.mode == "llm"
2226 || router.config.mode == "round_robin"
2227 || router.config.mode == "local",
2228 "mode should be 'llm', 'local', or 'round_robin', got '{}'",
2229 router.config.mode
2230 );
2231 assert!(!router.config.fallback_executor.is_empty());
2233 assert!(!router.config.allowed_tools.is_empty());
2235 }
2236
2237 #[serial(cooldown_state)]
2240 #[test]
2241 fn rate_limit_state_defaults_to_full_weight() {
2242 let state = RateLimitState::default();
2243 assert_eq!(state.weight, DEFAULT_WEIGHT);
2244 assert_eq!(state.consecutive_hits, 0);
2245 assert!(state.last_limited_at.is_none());
2246 assert!(!state.is_limited());
2247 }
2248
2249 #[serial(cooldown_state)]
2250 #[test]
2251 fn rate_limit_state_decays_on_hit() {
2252 let mut state = RateLimitState::default();
2253 state.record_rate_limit();
2254
2255 assert!(state.weight < DEFAULT_WEIGHT);
2256 assert_eq!(state.consecutive_hits, 1);
2257 assert!(state.last_limited_at.is_some());
2258 assert!(state.is_limited());
2259
2260 let expected = DEFAULT_WEIGHT * RATE_LIMIT_DECAY;
2262 assert!((state.weight - expected).abs() < 1e-10);
2263 }
2264
2265 #[serial(cooldown_state)]
2266 #[test]
2267 fn rate_limit_state_never_drops_below_min() {
2268 let mut state = RateLimitState::default();
2269
2270 for _ in 0..100 {
2272 state.record_rate_limit();
2273 }
2274
2275 assert!(state.weight >= MIN_WEIGHT);
2276 assert_eq!(state.consecutive_hits, 100);
2277 }
2278
2279 #[serial(cooldown_state)]
2280 #[test]
2281 fn rate_limit_state_recovers_on_success() {
2282 let mut state = RateLimitState::default();
2283
2284 state.record_rate_limit();
2286 state.record_rate_limit();
2287 let after_decay = state.weight;
2288
2289 state.record_success();
2291 assert!(state.weight > after_decay);
2292 assert_eq!(state.consecutive_hits, 0);
2293 }
2294
2295 #[serial(cooldown_state)]
2296 #[test]
2297 fn rate_limit_state_success_caps_at_default() {
2298 let mut state = RateLimitState::default();
2299
2300 state.record_success();
2302 assert_eq!(state.weight, DEFAULT_WEIGHT);
2303 }
2304
2305 #[serial(cooldown_state)]
2306 #[test]
2307 fn agent_weights_ensure_agents() {
2308 let mut weights = AgentWeights::default();
2309 let agents = vec!["claude".to_string(), "codex".to_string()];
2310 weights.ensure_agents(&agents);
2311
2312 assert_eq!(weights.states.len(), 2);
2313 assert_eq!(weights.get_weight("claude"), DEFAULT_WEIGHT);
2314 assert_eq!(weights.get_weight("codex"), DEFAULT_WEIGHT);
2315 }
2316
2317 #[serial(cooldown_state)]
2318 #[test]
2319 fn agent_weights_ensure_agents_preserves_existing() {
2320 let mut weights = AgentWeights::default();
2321 let agents = vec!["claude".to_string(), "codex".to_string()];
2322 weights.ensure_agents(&agents);
2323
2324 weights.record_rate_limit("claude");
2326 let claude_weight = weights.get_weight("claude");
2327 assert!(claude_weight < DEFAULT_WEIGHT);
2328
2329 let agents2 = vec![
2331 "claude".to_string(),
2332 "codex".to_string(),
2333 "opencode".to_string(),
2334 ];
2335 weights.ensure_agents(&agents2);
2336
2337 assert_eq!(weights.get_weight("claude"), claude_weight);
2338 assert_eq!(weights.get_weight("opencode"), DEFAULT_WEIGHT);
2339 }
2340
2341 #[test]
2342 fn default_missing_weight_uses_minimum_of_configured() {
2343 use super::weights::default_missing_weight;
2344
2345 let mut configured = std::collections::HashMap::new();
2346 configured.insert("claude".to_string(), 0.2);
2347 configured.insert("codex".to_string(), 0.2);
2348 configured.insert("opencode".to_string(), 0.2);
2349 configured.insert("kimi".to_string(), 0.2);
2350
2351 assert_eq!(default_missing_weight(&configured), 0.2);
2354 }
2355
2356 #[test]
2357 fn default_missing_weight_falls_back_when_none_configured() {
2358 use super::weights::default_missing_weight;
2359
2360 let configured = std::collections::HashMap::new();
2361 assert_eq!(default_missing_weight(&configured), DEFAULT_WEIGHT);
2362 }
2363
2364 #[serial(cooldown_state)]
2365 #[test]
2366 fn ensure_agents_with_weights_missing_agent_does_not_outrank_configured() {
2367 let mut weights = AgentWeights::default();
2368 let mut configured = std::collections::HashMap::new();
2369 configured.insert("claude".to_string(), 0.2);
2370 configured.insert("codex".to_string(), 0.2);
2371 configured.insert("opencode".to_string(), 0.2);
2372 configured.insert("kimi".to_string(), 0.2);
2373
2374 let agents = vec![
2375 "claude".to_string(),
2376 "codex".to_string(),
2377 "opencode".to_string(),
2378 "kimi".to_string(),
2379 "minimax".to_string(),
2380 ];
2381 weights.ensure_agents_with_weights(&agents, &configured);
2382
2383 assert_eq!(weights.get_weight("minimax"), 0.2);
2386 assert_eq!(weights.get_weight("claude"), 0.2);
2387 }
2388
2389 #[serial(cooldown_state)]
2390 #[test]
2391 fn agent_weights_record_rate_limit() {
2392 let mut weights = AgentWeights::default();
2393 weights.ensure_agents(&["claude".to_string(), "codex".to_string()]);
2394
2395 weights.record_rate_limit("claude");
2396
2397 assert!(weights.get_weight("claude") < DEFAULT_WEIGHT);
2398 assert_eq!(weights.get_weight("codex"), DEFAULT_WEIGHT);
2399 }
2400
2401 #[serial(cooldown_state)]
2402 #[test]
2403 fn agent_weights_record_success() {
2404 let mut weights = AgentWeights::default();
2405 weights.ensure_agents(&["claude".to_string()]);
2406
2407 weights.record_rate_limit("claude");
2408 let after_limit = weights.get_weight("claude");
2409
2410 weights.record_success("claude");
2411 assert!(weights.get_weight("claude") > after_limit);
2412 }
2413
2414 #[serial(cooldown_state)]
2415 #[test]
2416 fn agent_weights_snapshot() {
2417 let mut weights = AgentWeights::default();
2418 weights.ensure_agents(&["claude".to_string(), "codex".to_string()]);
2419 weights.record_rate_limit("claude");
2420
2421 let snap = weights.snapshot();
2422 assert_eq!(snap.len(), 2);
2423
2424 assert_eq!(snap[0].0, "claude");
2426 assert!(snap[0].1 < DEFAULT_WEIGHT);
2427 assert_eq!(snap[0].2, 1); assert_eq!(snap[1].0, "codex");
2430 assert_eq!(snap[1].1, DEFAULT_WEIGHT);
2431 assert_eq!(snap[1].2, 0); }
2433
2434 #[serial(cooldown_state)]
2435 #[test]
2436 fn agent_weights_weighted_select_favors_higher_weight() {
2437 let mut weights = AgentWeights::default();
2438 let agents = vec!["claude".to_string(), "codex".to_string()];
2439 weights.ensure_agents(&agents);
2440
2441 for _ in 0..10 {
2443 weights.record_rate_limit("claude");
2444 }
2445
2446 let mut claude_count = 0;
2448 let mut codex_count = 0;
2449 for _ in 0..100 {
2450 match weights.weighted_select(&agents, "task-1") {
2451 Some(ref a) if a == "claude" => claude_count += 1,
2452 Some(ref a) if a == "codex" => codex_count += 1,
2453 _ => {}
2454 }
2455 }
2456
2457 assert!(
2460 codex_count > claude_count,
2461 "codex ({codex_count}) should get more selections than claude ({claude_count})"
2462 );
2463 }
2464
2465 #[serial(cooldown_state)]
2466 #[test]
2467 fn agent_weights_weighted_select_empty_returns_none() {
2468 let weights = AgentWeights::default();
2469 assert!(weights.weighted_select(&[], "task-1").is_none());
2470 }
2471
2472 #[serial(cooldown_state)]
2473 #[test]
2474 fn agent_weights_batch_routing_produces_varied_selections() {
2475 let mut weights = AgentWeights::default();
2478 let agents = vec!["claude".to_string(), "codex".to_string()];
2479 weights.ensure_agents(&agents);
2480
2481 let task_ids: Vec<String> = (1..=20).map(|i| format!("task-{i}")).collect();
2483 let selections: Vec<String> = task_ids
2484 .iter()
2485 .filter_map(|id| weights.weighted_select(&agents, id))
2486 .collect();
2487
2488 let claude_count = selections.iter().filter(|a| a.as_str() == "claude").count();
2491 let codex_count = selections.iter().filter(|a| a.as_str() == "codex").count();
2492 assert!(
2493 claude_count > 0 && codex_count > 0,
2494 "batch routing should distribute across agents; got claude={claude_count} codex={codex_count}"
2495 );
2496 }
2497
2498 #[serial(cooldown_state)]
2499 #[test]
2500 fn agent_weights_weighted_select_single_agent() {
2501 let mut weights = AgentWeights::default();
2502 let agents = vec!["claude".to_string()];
2503 weights.ensure_agents(&agents);
2504
2505 let selected = weights.weighted_select(&agents, "task-1");
2506 assert_eq!(selected, Some("claude".to_string()));
2507 }
2508
2509 #[serial(cooldown_state)]
2510 #[test]
2511 fn agent_weights_tick_recovery_restores_weight() {
2512 let mut weights = AgentWeights::default();
2513 weights.ensure_agents(&["claude".to_string()]);
2514
2515 let state = weights.states.get_mut("claude").unwrap();
2517 state.weight = 0.5;
2518 state.last_limited_at = Some(Instant::now() - RECOVERY_DELAY - Duration::from_secs(1));
2519 state.consecutive_hits = 2;
2520
2521 let before = weights.get_weight("claude");
2522 weights.tick_recovery();
2523 let after = weights.get_weight("claude");
2524
2525 assert!(after > before, "weight should increase after recovery tick");
2526 }
2527
2528 #[serial(cooldown_state)]
2529 #[test]
2530 fn agent_weights_tick_recovery_clears_on_full_restore() {
2531 let mut weights = AgentWeights::default();
2532 weights.ensure_agents(&["claude".to_string()]);
2533
2534 let state = weights.states.get_mut("claude").unwrap();
2536 state.weight = DEFAULT_WEIGHT - 0.01;
2537 state.last_limited_at = Some(Instant::now() - RECOVERY_DELAY - Duration::from_secs(1));
2538 state.consecutive_hits = 1;
2539
2540 weights.tick_recovery();
2541
2542 let state = weights.states.get("claude").unwrap();
2543 assert_eq!(state.weight, DEFAULT_WEIGHT);
2544 assert!(state.last_limited_at.is_none());
2545 assert_eq!(state.consecutive_hits, 0);
2546 }
2547
2548 #[serial(cooldown_state)]
2549 #[tokio::test]
2550 async fn route_weighted_round_robin_basic() {
2551 crate::engine::cooldown::reset_global_state().await;
2552 let mut config = RouterConfig {
2553 weighted_round_robin: true,
2554 ..Default::default()
2555 };
2556 config
2558 .model_map
2559 .entry("medium".to_string())
2560 .or_default()
2561 .insert("codex".to_string(), vec!["gpt-5.2".to_string()]);
2562
2563 let agents = vec!["claude".to_string(), "codex".to_string()];
2564 let mut weights = AgentWeights::default();
2565 weights.ensure_agents(&agents);
2566 let mut router = Router {
2567 config,
2568 available_agents: agents,
2569 weights,
2570 llm_router: std::sync::Arc::new(LlmRouter::new()),
2571 ollama_router: None,
2572 rr_index: 0,
2573 last_agent: None,
2574 review_rr_index: 0,
2575 router_pool: vec![],
2576 pool_index: 0,
2577 };
2578
2579 let task = create_test_task("1", "Test task", vec![]);
2580 let store = test_store().await;
2581 let result = router.route(&task, &store, "test/repo").await.unwrap();
2582
2583 assert!(result.reason.contains("weighted_round_robin"));
2585 assert!(
2586 result.agent == "claude" || result.agent == "codex",
2587 "agent should be claude or codex, got '{}'",
2588 result.agent
2589 );
2590 }
2591
2592 #[serial(cooldown_state)]
2593 #[tokio::test]
2594 async fn route_weighted_round_robin_respects_label_override() {
2595 crate::engine::cooldown::reset_global_state().await;
2596 let mut config = RouterConfig {
2597 weighted_round_robin: true,
2598 ..Default::default()
2599 };
2600 config
2602 .model_map
2603 .entry("medium".to_string())
2604 .or_default()
2605 .insert("codex".to_string(), vec!["gpt-5.2".to_string()]);
2606
2607 let agents = vec!["claude".to_string(), "codex".to_string()];
2608 let mut weights = AgentWeights::default();
2609 weights.ensure_agents(&agents);
2610 let mut router = Router {
2611 config,
2612 available_agents: agents,
2613 weights,
2614 llm_router: std::sync::Arc::new(LlmRouter::new()),
2615 ollama_router: None,
2616 rr_index: 0,
2617 last_agent: None,
2618 review_rr_index: 0,
2619 router_pool: vec![],
2620 pool_index: 0,
2621 };
2622
2623 let task = create_test_task("1", "Test task", vec!["agent:codex".to_string()]);
2625 let store = test_store().await;
2626 let result = router.route(&task, &store, "test/repo").await.unwrap();
2627 assert_eq!(result.agent, "codex");
2628 assert!(result.reason.contains("label"));
2629 }
2630
2631 #[serial(cooldown_state)]
2632 #[test]
2633 fn router_config_weighted_round_robin_default_false() {
2634 let config = RouterConfig::default();
2635 assert!(!config.weighted_round_robin);
2636 }
2637
2638 #[serial(cooldown_state)]
2639 #[test]
2640 fn router_record_and_recover_weights() {
2641 let config = RouterConfig {
2642 weighted_round_robin: true,
2643 ..Default::default()
2644 };
2645 let mut router = Router::new(config);
2646 router.available_agents = vec!["claude".to_string(), "codex".to_string()];
2648 router.weights.ensure_agents(&router.available_agents);
2649
2650 router.record_rate_limit("claude");
2652 assert!(router.weights.get_weight("claude") < DEFAULT_WEIGHT);
2653 assert_eq!(router.weights.get_weight("codex"), DEFAULT_WEIGHT);
2654
2655 router.record_success("claude");
2657 let after_success = router.weights.get_weight("claude");
2658 assert!(after_success > MIN_WEIGHT);
2659 }
2660
2661 #[serial(cooldown_state)]
2662 #[test]
2663 fn review_rr_index_advances() {
2664 let mut config = RouterConfig::default();
2665 for agent in &["test_a", "test_b", "test_c"] {
2667 config
2668 .model_map
2669 .entry("medium".to_string())
2670 .or_default()
2671 .insert((*agent).to_string(), vec!["test-model".to_string()]);
2672 }
2673 let agents = vec![
2674 "test_a".to_string(),
2675 "test_b".to_string(),
2676 "test_c".to_string(),
2677 ];
2678 let mut weights = AgentWeights::default();
2679 weights.ensure_agents(&agents);
2680 let mut router = Router {
2681 config,
2682 available_agents: agents,
2683 weights,
2684 llm_router: std::sync::Arc::new(LlmRouter::new()),
2685 ollama_router: None,
2686 rr_index: 0,
2687 last_agent: None,
2688 review_rr_index: 0,
2689 router_pool: vec![],
2690 pool_index: 0,
2691 };
2692
2693 let a1 = router.next_round_robin_agent(&[], "medium").unwrap();
2694 let a2 = router.next_round_robin_agent(&[], "medium").unwrap();
2695 let a3 = router.next_round_robin_agent(&[], "medium").unwrap();
2696
2697 let mut seen = vec![a1, a2, a3];
2699 seen.sort();
2700 assert_eq!(seen, vec!["test_a", "test_b", "test_c"]);
2701 }
2702
2703 #[serial(cooldown_state)]
2704 #[test]
2705 fn review_rr_fallback_with_cooled_agent() {
2706 let mut config = RouterConfig::default();
2710 for agent in &["test_a", "test_b", "test_c"] {
2712 config
2713 .model_map
2714 .entry("medium".to_string())
2715 .or_default()
2716 .insert((*agent).to_string(), vec!["test-model".to_string()]);
2717 }
2718 let agents = vec!["test_a".to_string(), "test_b".to_string()];
2720 let mut weights = AgentWeights::default();
2721 weights.ensure_agents(&agents);
2722 let mut router = Router {
2723 config,
2724 available_agents: agents,
2725 weights,
2726 llm_router: std::sync::Arc::new(LlmRouter::new()),
2727 ollama_router: None,
2728 rr_index: 0,
2729 last_agent: None,
2730 review_rr_index: 2,
2732 router_pool: vec![],
2733 pool_index: 0,
2734 };
2735
2736 let result = router.next_round_robin_agent(&["test_a", "test_b"], "medium");
2738 assert!(
2739 result.is_some(),
2740 "next_round_robin_agent must not return None when candidates is non-empty"
2741 );
2742 }
2743
2744 #[serial(cooldown_state)]
2745 #[test]
2746 fn review_rr_index_uses_candidates_len_not_available_len() {
2747 let mut config = RouterConfig::default();
2752 for agent in &["test_a", "test_b"] {
2755 config
2756 .model_map
2757 .entry("medium".to_string())
2758 .or_default()
2759 .insert((*agent).to_string(), vec!["test-model".to_string()]);
2760 }
2761 let agents = vec![
2762 "test_a".to_string(),
2763 "test_b".to_string(),
2764 "test_c".to_string(), ];
2766 let mut weights = AgentWeights::default();
2767 weights.ensure_agents(&agents);
2768 let mut router = Router {
2769 config,
2770 available_agents: agents,
2771 weights,
2772 llm_router: std::sync::Arc::new(LlmRouter::new()),
2773 ollama_router: None,
2774 rr_index: 0,
2775 last_agent: None,
2776 review_rr_index: 0,
2777 router_pool: vec![],
2778 pool_index: 0,
2779 };
2780
2781 let r1 = router.next_round_robin_agent(&[], "medium").unwrap();
2790 assert_eq!(
2791 router.review_rr_index, 1,
2792 "index should be 1 after first call"
2793 );
2794
2795 let r2 = router.next_round_robin_agent(&[], "medium").unwrap();
2796 assert_eq!(
2797 router.review_rr_index, 0,
2798 "index should wrap to 0 after second call"
2799 );
2800
2801 let r3 = router.next_round_robin_agent(&[], "medium").unwrap();
2802 assert_eq!(
2803 router.review_rr_index, 1,
2804 "index should be 1 after third call"
2805 );
2806
2807 assert_ne!(r1, r2, "consecutive calls should select different agents");
2809 assert_eq!(r1, r3, "third call should match first (clean cycle)");
2810 }
2811
2812 #[serial(cooldown_state)]
2813 #[tokio::test]
2814 async fn last_agent_tracks_routing() {
2815 crate::engine::cooldown::reset_global_state().await;
2816 let mut config = RouterConfig {
2817 mode: "round_robin".to_string(),
2818 ..Default::default()
2819 };
2820 config
2821 .model_map
2822 .entry("medium".to_string())
2823 .or_default()
2824 .insert("test_x".to_string(), vec!["test-model-x".to_string()]);
2825 config
2826 .model_map
2827 .entry("medium".to_string())
2828 .or_default()
2829 .insert("test_y".to_string(), vec!["test-model-y".to_string()]);
2830 let agents = vec!["test_x".to_string(), "test_y".to_string()];
2831 let mut weights = AgentWeights::default();
2832 weights.ensure_agents(&agents);
2833 let mut router = Router {
2834 config,
2835 available_agents: agents,
2836 weights,
2837 llm_router: std::sync::Arc::new(LlmRouter::new()),
2838 ollama_router: None,
2839 rr_index: 0,
2840 last_agent: None,
2841 review_rr_index: 0,
2842 router_pool: vec![],
2843 pool_index: 0,
2844 };
2845
2846 assert!(router.last_agent.is_none());
2847
2848 let task = create_test_task("1", "Test routing", vec![]);
2849 let store = test_store().await;
2850 let result = router.route(&task, &store, "test/repo").await.unwrap();
2851
2852 assert!(router.last_agent.is_some());
2853 assert_eq!(router.last_agent.as_deref(), Some(result.agent.as_str()));
2854 }
2855
2856 #[serial(cooldown_state)]
2859 #[test]
2860 fn parse_pool_entry_splits_on_first_colon() {
2861 let (agent, model) = super::parse_pool_entry("claude:haiku");
2862 assert_eq!(agent, "claude");
2863 assert_eq!(model, "haiku");
2864 }
2865
2866 #[serial(cooldown_state)]
2867 #[test]
2868 fn parse_pool_entry_model_with_slash() {
2869 let (agent, model) = super::parse_pool_entry("opencode:github-copilot/gpt-5-mini");
2871 assert_eq!(agent, "opencode");
2872 assert_eq!(model, "github-copilot/gpt-5-mini");
2873 }
2874
2875 #[serial(cooldown_state)]
2876 #[test]
2877 fn parse_pool_entry_no_colon() {
2878 let (agent, model) = super::parse_pool_entry("claude");
2879 assert_eq!(agent, "claude");
2880 assert_eq!(model, "");
2881 }
2882
2883 #[serial(cooldown_state)]
2884 #[test]
2885 fn effective_pool_uses_configured_pool() {
2886 let config = RouterConfig {
2887 pool: vec!["kimi:k2p5".to_string(), "claude:haiku".to_string()],
2888 ..RouterConfig::default()
2889 };
2890 let pool = config.effective_pool();
2891 assert_eq!(pool, vec!["kimi:k2p5", "claude:haiku"]);
2892 }
2893
2894 #[serial(cooldown_state)]
2895 #[test]
2896 fn effective_pool_falls_back_to_router_agent_model() {
2897 let config = RouterConfig::default();
2898 let pool = config.effective_pool();
2900 assert_eq!(pool.len(), 1);
2901 assert!(pool[0].starts_with(&config.router_agent));
2902 assert!(pool[0].contains(&config.router_model));
2903 }
2904
2905 #[serial(cooldown_state)]
2906 #[test]
2907 fn effective_fallback_uses_configured_fallback() {
2908 let config = RouterConfig {
2909 fallback: "claude:haiku".to_string(),
2910 ..RouterConfig::default()
2911 };
2912 assert_eq!(config.effective_fallback(), "claude:haiku");
2913 }
2914
2915 #[serial(cooldown_state)]
2916 #[test]
2917 fn effective_fallback_derives_from_router_agent_model() {
2918 let config = RouterConfig::default();
2919 let fallback = config.effective_fallback();
2920 assert!(fallback.starts_with(&config.router_agent));
2921 assert!(fallback.contains(&config.router_model));
2922 }
2923
2924 #[serial(cooldown_state)]
2927 #[test]
2928 fn expand_pool_basic_entries() {
2929 let config = RouterConfig {
2930 pool: vec!["claude:haiku".to_string(), "kimi:k2p5".to_string()],
2931 ..RouterConfig::default()
2932 };
2933 let expanded = Router::expand_pool(&config);
2934 assert_eq!(expanded.len(), 2);
2935 assert_eq!(expanded[0], ("claude".to_string(), "haiku".to_string()));
2936 assert_eq!(expanded[1], ("kimi".to_string(), "k2p5".to_string()));
2937 }
2938
2939 #[serial(cooldown_state)]
2940 #[test]
2941 fn expand_pool_uses_effective_pool_when_pool_is_empty() {
2942 let config = RouterConfig {
2943 pool: vec![],
2944 router_agent: "claude".to_string(),
2945 router_model: "haiku".to_string(),
2946 ..RouterConfig::default()
2947 };
2948 let expanded = Router::expand_pool(&config);
2949 assert_eq!(expanded.len(), 1);
2950 assert_eq!(expanded[0], ("claude".to_string(), "haiku".to_string()));
2951 }
2952
2953 #[serial(cooldown_state)]
2954 #[test]
2955 fn expand_pool_opencode_free_skipped_when_not_installed() {
2956 if crate::cmd_cache::command_exists("opencode") {
2960 return;
2962 }
2963 let config = RouterConfig {
2964 pool: vec!["opencode:free".to_string()],
2965 fallback: "claude:haiku".to_string(),
2966 ..RouterConfig::default()
2967 };
2968 let expanded = Router::expand_pool(&config);
2969 assert_eq!(expanded.len(), 1);
2971 assert_eq!(expanded[0].0, "claude");
2972 assert_eq!(expanded[0].1, "haiku");
2973 }
2974
2975 #[serial(cooldown_state)]
2976 #[test]
2977 fn expand_pool_single_entry_from_default() {
2978 let config = RouterConfig::default();
2979 let expanded = Router::expand_pool(&config);
2980 assert!(
2981 !expanded.is_empty(),
2982 "pool must never be empty after expansion"
2983 );
2984 }
2985
2986 #[serial(cooldown_state)]
2989 #[test]
2990 fn pool_index_initializes_to_zero() {
2991 let config = RouterConfig::default();
2992 let router = Router::new(config);
2993 assert_eq!(router.pool_index, 0);
2994 }
2995
2996 #[serial(cooldown_state)]
2997 #[test]
2998 fn router_pool_non_empty_after_construction() {
2999 let config = RouterConfig::default();
3000 let router = Router::new(config);
3001 assert!(!router.router_pool.is_empty());
3002 }
3003
3004 #[serial(cooldown_state)]
3005 #[tokio::test]
3006 async fn reload_resets_pool_index() {
3007 crate::engine::cooldown::reset_global_state().await;
3008 let config = RouterConfig::default();
3009 let mut router = Router::new(config);
3010 router.pool_index = 5;
3011 router.reload_async().await;
3012 assert_eq!(router.pool_index, 0);
3013 }
3014
3015 #[serial(cooldown_state)]
3016 #[test]
3017 fn pool_index_advances_after_failed_attempt() {
3018 let mut router = Router::new(RouterConfig::default());
3019 router.router_pool = vec![
3020 ("claude".to_string(), "haiku".to_string()),
3021 ("codex".to_string(), "gpt-5.2".to_string()),
3022 (
3023 "opencode".to_string(),
3024 "github-copilot/gpt-5-mini".to_string(),
3025 ),
3026 ];
3027 router.pool_index = 0;
3028
3029 router.advance_pool_index_after_attempt(0, router.router_pool.len());
3030
3031 assert_eq!(router.pool_index, 1);
3032 }
3033
3034 #[serial(cooldown_state)]
3035 #[test]
3036 fn pool_index_wraps_after_last_attempt() {
3037 let mut router = Router::new(RouterConfig::default());
3038 router.router_pool = vec![
3039 ("claude".to_string(), "haiku".to_string()),
3040 ("codex".to_string(), "gpt-5.2".to_string()),
3041 (
3042 "opencode".to_string(),
3043 "github-copilot/gpt-5-mini".to_string(),
3044 ),
3045 ];
3046 router.pool_index = 2;
3047
3048 router.advance_pool_index_after_attempt(2, router.router_pool.len());
3049
3050 assert_eq!(router.pool_index, 0);
3051 }
3052
3053 #[serial(cooldown_state)]
3054 #[test]
3055 fn earliest_pool_cooldown_returns_none_when_no_cooldowns() {
3056 let router = Router::new(RouterConfig::default());
3057 assert!(router.earliest_pool_cooldown().is_none());
3058 }
3059
3060 #[serial(cooldown_state)]
3061 #[tokio::test]
3062 async fn earliest_pool_cooldown_returns_earliest_when_set() {
3063 crate::engine::cooldown::reset_global_state().await;
3064 let config = RouterConfig {
3066 pool: vec![
3067 "claude:haiku-4-5-20251001".to_string(),
3068 "opencode:haiku-4-5-20251001".to_string(),
3069 ],
3070 ..Default::default()
3071 };
3072 let router = Router::new(config);
3073 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();
3077 assert!(earliest.is_some());
3078 let now = chrono::Utc::now().timestamp();
3079 let remaining = earliest.unwrap() - now;
3080 assert!((295..=300).contains(&remaining));
3082 }
3083
3084 #[serial(cooldown_state)]
3085 #[tokio::test]
3086 async fn earliest_pool_cooldown_skips_non_pool_entries() {
3087 crate::engine::cooldown::reset_global_state().await;
3088 let config = RouterConfig {
3090 pool: vec![
3091 "claude:pool-model-a".to_string(),
3092 "opencode:pool-model-b".to_string(),
3093 ],
3094 ..Default::default()
3095 };
3096 let router = Router::new(config);
3097 set_model_cooldown("claude", "opus", 999, "silence_detected").await;
3099 set_model_cooldown("opencode", "pool-model-b", 300, "silence_detected").await;
3101
3102 let earliest = router.earliest_pool_cooldown();
3103 assert!(earliest.is_some());
3104 let now = chrono::Utc::now().timestamp();
3105 let remaining = earliest.unwrap() - now;
3106 assert!((295..=300).contains(&remaining));
3108 }
3109
3110 #[serial(cooldown_state)]
3113 #[test]
3114 fn degraded_agent_excluded_from_routing() {
3115 let mut config = RouterConfig::default();
3116 config
3117 .model_map
3118 .entry("medium".to_string())
3119 .or_default()
3120 .insert(
3121 "test_degraded_routing".to_string(),
3122 vec!["model-a".to_string()],
3123 );
3124
3125 let mut router = Router::new(config);
3126 router.available_agents = vec!["test_degraded_routing".to_string()];
3127
3128 assert!(router.agent_is_routable("test_degraded_routing", "medium"));
3130
3131 mark_agent_degraded("test_degraded_routing");
3133
3134 assert!(!router.agent_is_routable("test_degraded_routing", "medium"));
3136
3137 assert!(
3139 router.available_agents_for_complexity("medium").is_empty(),
3140 "degraded agent should be excluded from available agents"
3141 );
3142
3143 assert_eq!(router.healthy_agent_count("medium"), 0);
3145
3146 clear_agent_degraded("test_degraded_routing");
3148 }
3149
3150 #[serial(cooldown_state)]
3151 #[test]
3152 fn healthy_agents_skips_degraded_but_keeps_healthy() {
3153 let mut config = RouterConfig::default();
3154 for agent in &["agent_healthy", "agent_degraded"] {
3155 config
3156 .model_map
3157 .entry("medium".to_string())
3158 .or_default()
3159 .insert(agent.to_string(), vec!["model-x".to_string()]);
3160 }
3161
3162 let mut router = Router::new(config);
3163 router.available_agents = vec!["agent_healthy".to_string(), "agent_degraded".to_string()];
3164
3165 mark_agent_degraded("agent_degraded");
3166
3167 let candidates = router.available_agents_for_complexity("medium");
3168 assert_eq!(candidates, vec!["agent_healthy".to_string()]);
3169 assert_eq!(router.healthy_agent_count("medium"), 1);
3170
3171 clear_agent_degraded("agent_degraded");
3173 }
3174
3175 #[serial(cooldown_state)]
3176 #[tokio::test]
3177 async fn refresh_health_marks_degraded_from_rate_limits() {
3178 crate::engine::cooldown::reset_global_state().await;
3179 let store = test_store().await;
3180 let agent = "test_refresh_health_agent";
3181
3182 let mut config = RouterConfig::default();
3183 config
3184 .model_map
3185 .entry("medium".to_string())
3186 .or_default()
3187 .insert(agent.to_string(), vec!["model-z".to_string()]);
3188
3189 let mut router = Router::new(config);
3190 router.available_agents = vec![agent.to_string()];
3191
3192 for _ in 0..4 {
3194 store
3195 .record_rate_limit(agent, "rate_limit", None)
3196 .await
3197 .unwrap();
3198 }
3199
3200 router.refresh_health(&store).await;
3201
3202 assert!(
3203 is_agent_degraded(agent),
3204 "agent should be degraded after exceeding rate limit threshold"
3205 );
3206 assert!(
3207 !router.agent_is_routable(agent, "medium"),
3208 "degraded agent should not be routable"
3209 );
3210
3211 clear_agent_degraded(agent);
3213 }
3214
3215 #[serial(cooldown_state)]
3218 #[tokio::test]
3219 async fn agent_has_any_available_model_false_when_only_model_cooled() {
3220 crate::engine::cooldown::reset_global_state().await;
3221 let mut config = RouterConfig::default();
3222 for tier in ["simple", "medium", "complex", "review"] {
3224 config
3225 .model_map
3226 .entry(tier.to_string())
3227 .or_default()
3228 .insert(
3229 "test_all_cooled".to_string(),
3230 vec!["only-model".to_string()],
3231 );
3232 }
3233
3234 let mut router = Router::new(config);
3235 router.available_agents = vec!["test_all_cooled".to_string()];
3236
3237 assert!(router.agent_has_any_available_model("test_all_cooled"));
3238
3239 record_model_failure("test_all_cooled", "only-model").await;
3240 assert!(is_model_in_cooldown("test_all_cooled", "only-model"));
3241
3242 assert!(
3243 !router.agent_has_any_available_model("test_all_cooled"),
3244 "agent with its only model cooled across every tier should have no available model"
3245 );
3246 }
3247
3248 #[serial(cooldown_state)]
3249 #[tokio::test]
3250 async fn llm_candidate_excludes_agent_with_all_models_cooled() {
3251 crate::engine::cooldown::reset_global_state().await;
3255 let mut config = RouterConfig::default();
3256 for tier in ["simple", "medium", "complex", "review"] {
3257 config
3258 .model_map
3259 .entry(tier.to_string())
3260 .or_default()
3261 .insert("agent_cooled_model".to_string(), vec!["opus".to_string()]);
3262 config
3263 .model_map
3264 .get_mut(tier)
3265 .unwrap()
3266 .insert("agent_healthy".to_string(), vec!["model-x".to_string()]);
3267 }
3268
3269 let mut router = Router::new(config);
3270 router.available_agents = vec![
3271 "agent_cooled_model".to_string(),
3272 "agent_healthy".to_string(),
3273 ];
3274
3275 record_model_failure("agent_cooled_model", "opus").await;
3276 assert!(is_model_in_cooldown("agent_cooled_model", "opus"));
3277 assert!(!is_agent_in_cooldown("agent_cooled_model"));
3279
3280 let uncooled_agents: Vec<String> = router
3281 .available_agents
3282 .iter()
3283 .filter(|a| {
3284 !is_agent_in_cooldown(a)
3285 && !is_agent_degraded(a)
3286 && router.agent_has_available_model_for_coding_tiers(a)
3287 })
3288 .cloned()
3289 .collect();
3290
3291 assert_eq!(
3292 uncooled_agents,
3293 vec!["agent_healthy".to_string()],
3294 "agent whose only model is cooled must be excluded from the LLM candidate list"
3295 );
3296 }
3297
3298 #[serial(cooldown_state)]
3299 #[tokio::test]
3300 async fn llm_candidate_excludes_agent_with_only_simple_tier_uncooled() {
3301 crate::engine::cooldown::reset_global_state().await;
3308 let mut config = RouterConfig::default();
3309 config
3310 .model_map
3311 .entry("simple".to_string())
3312 .or_default()
3313 .insert("minimax_like".to_string(), vec!["sonnet".to_string()]);
3314 config
3315 .model_map
3316 .entry("medium".to_string())
3317 .or_default()
3318 .insert("minimax_like".to_string(), vec!["opus".to_string()]);
3319 config
3320 .model_map
3321 .entry("complex".to_string())
3322 .or_default()
3323 .insert("minimax_like".to_string(), vec!["opus".to_string()]);
3324 config
3326 .model_map
3327 .entry("review".to_string())
3328 .or_default()
3329 .insert("minimax_like".to_string(), vec!["opus".to_string()]);
3330 for tier in ["simple", "medium", "complex", "review"] {
3331 config
3332 .model_map
3333 .get_mut(tier)
3334 .unwrap()
3335 .insert("agent_healthy".to_string(), vec!["model-x".to_string()]);
3336 }
3337
3338 let mut router = Router::new(config);
3339 router.available_agents = vec!["minimax_like".to_string(), "agent_healthy".to_string()];
3340
3341 record_model_failure("minimax_like", "opus").await;
3342 assert!(is_model_in_cooldown("minimax_like", "opus"));
3343 assert!(!is_agent_in_cooldown("minimax_like"));
3346 assert!(router.agent_has_any_available_model("minimax_like"));
3347 assert!(!router.agent_has_available_model_for_coding_tiers("minimax_like"));
3348
3349 let uncooled_agents: Vec<String> = router
3350 .available_agents
3351 .iter()
3352 .filter(|a| {
3353 !is_agent_in_cooldown(a)
3354 && !is_agent_degraded(a)
3355 && router.agent_has_available_model_for_coding_tiers(a)
3356 })
3357 .cloned()
3358 .collect();
3359
3360 assert_eq!(
3361 uncooled_agents,
3362 vec!["agent_healthy".to_string()],
3363 "agent with only its simple tier uncooled must be excluded from the LLM candidate list"
3364 );
3365 }
3366}