pub mod config;
mod llm;
mod ollama;
mod selection;
mod strategies;
pub mod weights;
pub use config::{parse_pool_entry, RouterConfig};
pub use weights::AgentWeights;
#[derive(Debug, thiserror::Error)]
pub enum AllAgentsCooledError {
#[error("all agents/models in cooldown for {scope}")]
AllCooled {
scope: String,
remaining_secs: Option<u64>,
},
}
impl AllAgentsCooledError {
pub fn new(scope: String, remaining_secs: Option<u64>) -> Self {
Self::AllCooled {
scope,
remaining_secs,
}
}
pub fn scope(&self) -> &str {
match self {
Self::AllCooled { scope, .. } => scope,
}
}
pub fn remaining_secs(&self) -> Option<u64> {
match self {
Self::AllCooled { remaining_secs, .. } => *remaining_secs,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum RouteResultError {
#[error("no agent configured for task {task_id}")]
NoAgent { task_id: String },
#[error(transparent)]
Other(#[from] anyhow::Error),
}
use crate::backends::ExternalTask;
use crate::engine::cooldown::{
cooldown_until, is_agent_degraded, is_agent_in_cooldown, is_model_in_cooldown,
record_agent_success, refresh_degraded_agents, sync_from_kv,
};
use crate::store::{get_task_field_direct, store_log_activity, TaskStore};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use llm::{LlmRouter, TIMEOUT_PREFIX};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RouteResult {
pub agent: String,
pub model: Option<String>,
pub complexity: String,
pub estimate: u8,
pub reason: String,
pub profile: AgentProfile,
pub selected_skills: Vec<String>,
pub warning: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct AgentProfile {
pub role: String,
pub skills: Vec<String>,
pub tools: Vec<String>,
pub constraints: Vec<String>,
}
impl AgentProfile {
pub fn default_from_config(config: &RouterConfig) -> Self {
Self {
role: "general".to_string(),
skills: vec![],
tools: config.allowed_tools.clone(),
constraints: vec![],
}
}
}
impl RouteResult {
pub fn from_config(
agent: String,
complexity: String,
reason: String,
config: &RouterConfig,
task_id: &str,
) -> Self {
let model = config.model_for_complexity(&agent, &complexity, task_id);
Self {
agent,
model,
complexity,
estimate: 0,
reason,
profile: AgentProfile::default_from_config(config),
selected_skills: config.default_skills.clone(),
warning: None,
}
}
}
pub struct LlmRouterHandle(std::sync::Arc<LlmRouter>);
impl LlmRouterHandle {
pub async fn invalidate_skills_catalog(&self) {
self.0.invalidate_skills_catalog().await;
}
}
pub struct Router {
pub config: RouterConfig,
pub available_agents: Vec<String>,
pub weights: AgentWeights,
llm_router: std::sync::Arc<LlmRouter>,
ollama_router: Option<ollama::OllamaRouter>,
pub(crate) rr_index: usize,
pub(crate) last_agent: Option<String>,
pub(crate) review_rr_index: usize,
pub(crate) router_pool: Vec<(String, String)>,
pub(crate) pool_index: usize,
}
impl Router {
pub fn new(config: RouterConfig) -> Self {
let available_agents = Self::discover_agents(&config.agents);
let mut weights = AgentWeights {
base_weights: config.weights.clone(),
..Default::default()
};
weights.ensure_agents_with_weights(&available_agents, &config.weights);
if !config.weights.is_empty() {
tracing::info!(
weights = ?config.weights,
"configured agent routing weights"
);
}
let router_pool = Self::expand_pool(&config);
tracing::info!(
pool = ?router_pool,
fallback = %config.effective_fallback(),
"router LLM pool initialized"
);
crate::engine::runner::agents::opencode::prime_free_model_cache();
let ollama_router = if config.mode == "local" {
Some(ollama::OllamaRouter::new(&config))
} else {
None
};
Self {
config,
available_agents,
weights,
llm_router: std::sync::Arc::new(LlmRouter::new()),
ollama_router,
rr_index: 0,
last_agent: None,
review_rr_index: 0,
router_pool,
pool_index: 0,
}
}
pub fn from_config() -> Self {
Self::new(RouterConfig::from_config())
}
#[cfg(test)]
pub(crate) fn new_for_test(config: RouterConfig, available_agents: Vec<String>) -> Self {
let mut weights = AgentWeights {
base_weights: config.weights.clone(),
..Default::default()
};
weights.ensure_agents(&available_agents);
let router_pool = Self::expand_pool(&config);
Self {
config,
available_agents,
weights,
llm_router: std::sync::Arc::new(LlmRouter::new()),
ollama_router: None,
rr_index: 0,
last_agent: None,
review_rr_index: 0,
router_pool,
pool_index: 0,
}
}
pub fn llm_router_handle(&self) -> LlmRouterHandle {
LlmRouterHandle(self.llm_router.clone())
}
fn advance_pool_index_after_attempt(&mut self, idx: usize, pool_len: usize) {
debug_assert!(pool_len > 0, "router pool must not be empty");
self.pool_index = (idx + 1) % pool_len;
}
fn discover_agents(configured_agents: &[String]) -> Vec<String> {
let mut agents = Vec::new();
for agent in configured_agents {
if crate::cmd_cache::command_exists(agent) {
agents.push(agent.to_string());
}
}
agents
}
fn expand_pool(config: &RouterConfig) -> Vec<(String, String)> {
let raw_pool = config.effective_pool();
let mut expanded: Vec<(String, String)> = Vec::new();
for entry in &raw_pool {
if entry == "opencode:free" {
let free_models = Self::discover_free_opencode_models();
if free_models.is_empty() {
tracing::debug!("opencode:free expanded to nothing — skipping");
} else {
tracing::debug!(models = ?free_models, "opencode:free expanded");
for model in free_models {
expanded.push(("opencode".to_string(), model));
}
}
} else {
let (agent, model) = parse_pool_entry(entry);
expanded.push((agent, model));
}
}
if expanded.is_empty() {
let (agent, model) = parse_pool_entry(&config.effective_fallback());
tracing::debug!(
agent = %agent, model = %model,
"pool empty after expansion, using fallback as sole pool entry"
);
expanded.push((agent, model));
}
expanded
}
async fn expand_pool_async(config: &RouterConfig) -> Vec<(String, String)> {
let raw_pool = config.effective_pool();
let mut expanded: Vec<(String, String)> = Vec::new();
for entry in &raw_pool {
if entry == "opencode:free" {
let free_models = Self::discover_free_opencode_models_async().await;
if free_models.is_empty() {
tracing::debug!("opencode:free expanded to nothing — skipping (async)");
} else {
tracing::debug!(models = ?free_models, "opencode:free expanded (async)");
for model in free_models {
expanded.push(("opencode".to_string(), model));
}
}
} else {
let (agent, model) = parse_pool_entry(entry);
expanded.push((agent, model));
}
}
if expanded.is_empty() {
let (agent, model) = parse_pool_entry(&config.effective_fallback());
tracing::debug!(
agent = %agent, model = %model,
"pool empty after async expansion, using fallback as sole pool entry"
);
expanded.push((agent, model));
}
expanded
}
pub async fn reload_async(&mut self) {
let new_config = RouterConfig::from_config();
let new_agents = Self::discover_agents(&new_config.agents);
let new_pool = Self::expand_pool_async(&new_config).await;
tracing::info!(
mode = %new_config.mode,
agents = ?new_agents,
fallback = %new_config.fallback_executor,
weighted_rr = new_config.weighted_round_robin,
pool = ?new_pool,
"router reloaded (async)"
);
self.config = new_config;
self.available_agents = new_agents.clone();
self.weights.ensure_agents(&new_agents);
self.router_pool = new_pool;
self.pool_index = 0;
self.ollama_router = if self.config.mode == "local" {
Some(ollama::OllamaRouter::new(&self.config))
} else {
None
};
}
fn discover_free_opencode_models() -> Vec<String> {
crate::engine::runner::agents::opencode::discover_free_opencode_models()
}
async fn discover_free_opencode_models_async() -> Vec<String> {
tokio::task::spawn_blocking(Self::discover_free_opencode_models)
.await
.unwrap_or_default()
}
pub async fn refresh_health(&self, store: &std::sync::Arc<TaskStore>) {
sync_from_kv(store).await;
let agents = self.available_agents.clone();
let model_checker = |agent: &str| -> bool { self.agent_has_any_available_model(agent) };
refresh_degraded_agents(
store,
&agents,
&model_checker,
config::health_check_window_hours(),
config::degraded_rate_limit_threshold(),
)
.await;
}
pub fn is_agent_available(&self, agent: &str) -> bool {
self.available_agents.contains(&agent.to_string())
}
fn agent_has_any_available_model(&self, agent: &str) -> bool {
["simple", "medium", "complex", "review"]
.iter()
.any(|comp| self.config.has_available_model_for_complexity(agent, comp))
}
fn agent_has_available_model_for_coding_tiers(&self, agent: &str) -> bool {
["medium", "complex"]
.iter()
.all(|comp| self.config.has_available_model_for_complexity(agent, comp))
}
fn agent_is_routable(&self, agent: &str, complexity: &str) -> bool {
if !self.is_agent_available(agent) {
return false;
}
if is_agent_in_cooldown(agent) {
tracing::debug!(agent, "agent skipped: in cooldown");
return false;
}
if is_agent_degraded(agent) {
tracing::debug!(agent, "agent skipped: degraded (pre-emptive health check)");
return false;
}
if self.config.weighted_round_robin {
let weight = self.weights.get_weight(agent);
if weight < self.config.skip_limited_threshold {
tracing::debug!(
agent,
weight,
"agent considered degraded by weight threshold, skipping"
);
return false;
}
}
if !self
.config
.has_available_model_for_complexity(agent, complexity)
{
tracing::debug!(agent, complexity, "agent skipped: no available model");
return false;
}
true
}
fn available_agents_for_complexity(&self, complexity: &str) -> Vec<String> {
self.available_agents
.iter()
.filter(|agent| self.agent_is_routable(agent, complexity))
.cloned()
.collect()
}
pub fn healthy_agent_count(&self, complexity: &str) -> usize {
self.available_agents_for_complexity(complexity).len()
}
pub fn is_degraded(&self, threshold: usize) -> bool {
self.healthy_agent_count("simple") < threshold
}
pub fn earliest_cooldown_until(&self, complexity: Option<&str>) -> Option<i64> {
let mut earliest: Option<i64> = None;
for agent in &self.available_agents {
if let Some(until) = cooldown_until(agent) {
earliest = Some(earliest.map_or(until, |current| current.min(until)));
}
if let Some(comp) = complexity {
if let Some(pool) = self.config.model_pool_for_complexity(agent, comp) {
for model in pool {
let key = format!("{agent}:{model}");
if let Some(until) = cooldown_until(&key) {
earliest = Some(earliest.map_or(until, |current| current.min(until)));
}
}
}
}
}
earliest
}
fn earliest_pool_cooldown(&self) -> Option<i64> {
let mut earliest: Option<i64> = None;
for (agent, model) in &self.router_pool {
let key = format!("{agent}:{model}");
if let Some(until) = cooldown_until(&key) {
earliest = Some(earliest.map_or(until, |current| current.min(until)));
}
}
earliest
}
async fn wait_for_cooldown(&self, complexity: Option<&str>) -> anyhow::Result<()> {
let now = chrono::Utc::now().timestamp();
let Some(earliest) = self.earliest_cooldown_until(complexity) else {
tracing::warn!(
scope = complexity.unwrap_or("all agents"),
"all agents degraded (no cooldown timestamp available) — skipping task, will retry after backoff"
);
return Err(AllAgentsCooledError::new(
complexity.unwrap_or("all agents").to_string(),
None,
)
.into());
};
let remaining = earliest.saturating_sub(now);
if remaining > 0 {
let scope_str = complexity.unwrap_or("all agents").to_string();
tracing::warn!(
remaining_secs = remaining,
scope = %scope_str,
"all agents/models cooled — routing is unavailable until cooldown expires; failing fast to let tick retry"
);
return Err(AllAgentsCooledError::new(scope_str, Some(remaining as u64)).into());
}
Ok(())
}
pub fn next_round_robin_agent(&mut self, exclude: &[&str], complexity: &str) -> Option<String> {
let candidates = self.available_agents_for_complexity(complexity);
if candidates.is_empty() {
return None;
}
let idx = self.review_rr_index;
let n = candidates.len();
let agent = (0..n)
.map(|offset| &candidates[(idx + offset) % n])
.find(|a| !exclude.contains(&a.as_str()))
.cloned()
.or_else(|| candidates.first().cloned())?;
self.review_rr_index = (idx + 1) % candidates.len().max(1);
Some(agent)
}
async fn skip_no_code_agent(
&self,
store: &std::sync::Arc<TaskStore>,
repo: &str,
task_id: &str,
candidates: &[String],
) -> Option<Vec<String>> {
let no_code_agent = get_task_field_direct(store, repo, task_id, "no_code_last_agent")
.await
.unwrap_or_else(|| {
tracing::warn!(
task_id,
"no_code_last_agent DB read returned None — loop detection may be impaired"
);
String::new()
});
if no_code_agent.is_empty() || !candidates.iter().any(|a| a == &no_code_agent) {
return None;
}
let filtered: Vec<String> = candidates
.iter()
.filter(|a| a.as_str() != no_code_agent)
.cloned()
.collect();
if filtered.is_empty() {
tracing::warn!(
task_id,
no_code_agent = %no_code_agent,
"all available agents are the no-code agent — cannot skip"
);
return None;
}
tracing::info!(
task_id,
skipped = %no_code_agent,
remaining = ?filtered,
"skipping no-code agent, routing to alternative"
);
Some(filtered)
}
pub async fn route(
&mut self,
task: &ExternalTask,
store: &std::sync::Arc<TaskStore>,
repo: &str,
) -> anyhow::Result<RouteResult> {
loop {
let store_agent = match store.get_agent_by_external_id(repo, &task.id.0).await {
Ok(Some(agent)) => {
if !agent.is_empty() && self.config.agents.iter().any(|cfg| cfg == &agent) {
Some(agent)
} else {
None
}
}
Ok(None) => None,
Err(e) => {
tracing::warn!(
repo,
task_id = %task.id.0,
err = %e,
"failed to fetch stored agent, falling back to label/LLM routing"
);
None
}
};
let resolved_agent = store_agent.or_else(|| {
strategies::extract_agent_from_labels(&self.config.agents, &task.labels)
});
if let Some(agent) = resolved_agent {
let complexity = strategies::extract_complexity_from_labels(&task.labels);
if self.agent_is_routable(&agent, &complexity) {
let model = self
.config
.model_for_complexity(&agent, &complexity, &task.id.0);
if model.is_some() {
tracing::debug!(
task_id = %task.id.0,
agent = %agent,
complexity = %complexity,
"routed via label"
);
let mut result = RouteResult::from_config(
agent.clone(),
complexity.clone(),
format!("label agent:{agent}"),
&self.config,
&task.id.0,
);
result.profile.role = format!("{} specialist", agent);
result.model = model.clone();
self.log_route_activity(store, repo, &task.id.0, &result, None)
.await;
return Ok(result);
} else {
tracing::warn!(task_id = %task.id.0, agent = %agent, "label agent has no available model — falling through to LLM routing");
}
}
let candidates = self.available_agents_for_complexity(&complexity);
if candidates.is_empty() {
self.wait_for_cooldown(Some(&complexity)).await?;
continue;
}
}
let complexity = strategies::extract_complexity_from_labels(&task.labels);
let mut candidates = self.available_agents_for_complexity(&complexity);
if candidates.is_empty() {
self.wait_for_cooldown(Some(&complexity)).await?;
continue;
}
if let Some(filtered) = self
.skip_no_code_agent(store, repo, &task.id.0, &candidates)
.await
{
candidates = filtered;
}
if self.config.weighted_round_robin {
let result = strategies::route_via_weighted_round_robin(
&candidates,
&self.weights,
&self.config,
task,
&mut self.last_agent,
)?;
self.log_route_activity(store, repo, &task.id.0, &result, None)
.await;
return Ok(result);
}
if self.config.mode == "round_robin" {
tracing::debug!(task_id = %task.id.0, "routing via round-robin mode");
let result = strategies::route_via_round_robin_stateful(
&candidates,
&self.config,
task,
&mut self.rr_index,
&mut self.last_agent,
)?;
self.log_route_activity(store, repo, &task.id.0, &result, None)
.await;
return Ok(result);
}
if self.config.mode == "local" {
tracing::debug!(task_id = %task.id.0, ollama_url = %self.config.ollama_url, "routing via local Ollama");
let ollama_router = self
.ollama_router
.as_ref()
.expect("ollama_router must be initialized in Router::new when mode=local");
match ollama_router.route(task, &self.config).await {
Ok(result) => {
self.log_route_activity(store, repo, &task.id.0, &result, None)
.await;
return Ok(result);
}
Err(e) => {
tracing::warn!(
task_id = %task.id.0,
error = %e,
"Ollama routing failed, falling back to round-robin"
);
let result = strategies::route_via_round_robin_stateful(
&candidates,
&self.config,
task,
&mut self.rr_index,
&mut self.last_agent,
)?;
self.log_route_activity(store, repo, &task.id.0, &result, None)
.await;
return Ok(result);
}
}
}
let route_attempts = self.get_route_attempts(&task.id.0, store, repo).await;
if route_attempts >= self.config.max_route_attempts {
tracing::warn!(
task_id = %task.id.0,
attempts = route_attempts,
max = self.config.max_route_attempts,
"max LLM route attempts reached, falling back to round-robin"
);
let candidates = self.available_agents_for_complexity(&complexity);
if candidates.is_empty() {
self.wait_for_cooldown(Some(&complexity)).await?;
continue;
}
let result = strategies::route_via_round_robin_stateful(
&candidates,
&self.config,
task,
&mut self.rr_index,
&mut self.last_agent,
)?;
self.log_route_activity(store, repo, &task.id.0, &result, None)
.await;
return Ok(result);
}
tracing::debug!(task_id = %task.id.0, "starting LLM routing");
match self.route_with_llm(task, repo).await {
Ok(result) => {
let candidates = self.available_agents_for_complexity(&result.complexity);
if candidates.is_empty() {
self.wait_for_cooldown(Some(&result.complexity)).await?;
continue;
}
let no_code_agent =
get_task_field_direct(store, repo, &task.id.0, "no_code_last_agent")
.await
.unwrap_or_else(|| {
tracing::warn!(task_id = %task.id.0, "no_code_last_agent DB read returned None — loop detection may be impaired");
String::new()
});
if result.agent == no_code_agent {
let fallback_agent = candidates
.iter()
.find(|a| a.as_str() != no_code_agent)
.cloned()
.unwrap_or_else(|| candidates[0].clone());
if fallback_agent.as_str() == no_code_agent {
tracing::warn!(
task_id = %task.id.0,
no_code_agent = %no_code_agent,
complexity = %result.complexity,
"all available agents are the no-code agent — waiting for another agent"
);
self.wait_for_cooldown(Some(&result.complexity)).await?;
continue;
}
tracing::info!(
task_id = %task.id.0,
llm_selected = %result.agent,
skipped = %no_code_agent,
fallback = %fallback_agent,
"LLM selected the no-code agent — routing to alternative"
);
let fallback_model = self.config.model_for_complexity(
&fallback_agent,
&result.complexity,
&task.id.0,
);
self.set_route_attempts(&task.id.0, 0, store, repo).await;
let result = RouteResult {
agent: fallback_agent.clone(),
model: fallback_model,
complexity: result.complexity.clone(),
estimate: result.estimate,
reason: format!(
"LLM selected no-code agent {}; routed to {}",
result.agent, fallback_agent
),
profile: result.profile.clone(),
selected_skills: result.selected_skills.clone(),
warning: Some(format!(
"LLM selected no-code agent {}; used fallback {}",
result.agent, fallback_agent
)),
};
self.log_route_activity(store, repo, &task.id.0, &result, None)
.await;
return Ok(result);
}
let model_cooled = result
.model
.as_deref()
.is_some_and(|model| is_model_in_cooldown(&result.agent, model));
if !candidates.contains(&result.agent) || model_cooled {
let fallback_agent = candidates
.iter()
.find(|agent| *agent != &result.agent)
.cloned()
.unwrap_or_else(|| candidates[0].clone());
let fallback_model = self.config.model_for_complexity(
&fallback_agent,
&result.complexity,
&task.id.0,
);
if fallback_model.is_none() && fallback_agent == result.agent {
tracing::warn!(
task_id = %task.id.0,
agent = %result.agent,
complexity = %result.complexity,
"LLM-selected model(s) are cooled for the only available agent; waiting for cooldown"
);
self.wait_for_cooldown(Some(&result.complexity)).await?;
continue;
}
if fallback_agent == result.agent {
tracing::warn!(
task_id = %task.id.0,
agent = %result.agent,
complexity = %result.complexity,
"LLM-selected model was cooled; selecting an alternative model for the same agent"
);
} else {
tracing::warn!(
task_id = %task.id.0,
agent = %result.agent,
fallback = %fallback_agent,
complexity = %result.complexity,
"LLM selected cooled agent/model; rerouting to available agent"
);
}
self.set_route_attempts(&task.id.0, 0, store, repo).await;
let reason = if fallback_agent == result.agent {
format!(
"LLM selected cooled model; using same agent {} with different model",
fallback_agent
)
} else {
format!(
"LLM selected cooled agent/model; rerouted to {}",
fallback_agent
)
};
let result = RouteResult {
agent: fallback_agent.clone(),
model: fallback_model,
complexity: result.complexity.clone(),
estimate: result.estimate,
reason: reason.clone(),
profile: result.profile.clone(),
selected_skills: result.selected_skills.clone(),
warning: Some(if fallback_agent == result.agent {
"LLM-selected model was cooled; selected alternative model for same agent"
.to_string()
} else {
"LLM-selected agent/model was cooled; rerouted to available agent"
.to_string()
}),
};
self.log_route_activity(store, repo, &task.id.0, &result, None)
.await;
return Ok(result);
}
self.set_route_attempts(&task.id.0, 0, store, repo).await;
tracing::info!(
task_id = %task.id.0,
agent = %result.agent,
complexity = %result.complexity,
"routed via LLM"
);
self.log_route_activity(store, repo, &task.id.0, &result, None)
.await;
return Ok(result);
}
Err(e) => {
if let Some(err) = e.downcast_ref::<AllAgentsCooledError>() {
let scope = err.scope();
tracing::warn!(scope = %scope, "router cooldown gate tripped");
let scope_opt = match scope {
"all agents" | "router pool" => None,
_ => Some(scope),
};
self.wait_for_cooldown(scope_opt).await?;
continue;
}
let new_attempts = route_attempts + 1;
self.set_route_attempts(&task.id.0, new_attempts, store, repo)
.await;
tracing::warn!(
task_id = %task.id.0,
error = %e,
error_chain = ?e,
attempt = new_attempts,
max = self.config.max_route_attempts,
"LLM routing failed"
);
if new_attempts >= self.config.max_route_attempts {
tracing::info!(
task_id = %task.id.0,
"falling back to round-robin after {} failed attempts",
new_attempts
);
let candidates = self.available_agents_for_complexity(&complexity);
if candidates.is_empty() {
self.wait_for_cooldown(Some(&complexity)).await?;
continue;
}
let result = strategies::route_via_round_robin_stateful(
&candidates,
&self.config,
task,
&mut self.rr_index,
&mut self.last_agent,
)?;
self.log_route_activity(store, repo, &task.id.0, &result, None)
.await;
return Ok(result);
}
let candidates = self.available_agents_for_complexity(&complexity);
if candidates.is_empty() {
self.wait_for_cooldown(Some(&complexity)).await?;
continue;
}
let result = strategies::route_via_fallback(
&candidates,
&self.config,
task,
Some(&self.weights),
)?;
self.log_route_activity(store, repo, &task.id.0, &result, None)
.await;
return Ok(result);
}
}
}
}
async fn get_route_attempts(
&self,
task_id: &str,
store: &std::sync::Arc<TaskStore>,
repo: &str,
) -> u32 {
match store.resolve_task_id(repo, task_id).await {
Ok(Some(store_id)) => store
.get(store_id)
.await
.map(|t| t.route_attempts as u32)
.unwrap_or(0),
_ => 0,
}
}
async fn set_route_attempts(
&self,
task_id: &str,
attempts: u32,
store: &std::sync::Arc<TaskStore>,
repo: &str,
) {
if let Ok(Some(store_id)) = store.resolve_task_id(repo, task_id).await {
if let Err(e) = store
.set_fields(store_id, &[("route_attempts", serde_json::json!(attempts))])
.await
{
tracing::warn!(task_id, error = %e, "failed to store route_attempts");
}
}
}
async fn log_route_activity(
&self,
store: &std::sync::Arc<TaskStore>,
repo: &str,
task_id: &str,
result: &RouteResult,
from_agent: Option<&str>,
) {
let details = serde_json::json!({
"reason": result.reason,
"complexity": result.complexity,
"skills": result.selected_skills,
"role": result.profile.role,
"warning": result.warning,
});
let event_type = if from_agent.is_some() {
"rerouted"
} else {
"routed"
};
store_log_activity(
&Some(Arc::clone(store)),
repo,
task_id,
event_type,
None,
Some("routed"),
Some(&result.agent),
result.model.as_deref(),
Some(&details),
)
.await;
}
async fn route_with_llm(
&mut self,
task: &ExternalTask,
repo: &str,
) -> anyhow::Result<RouteResult> {
let uncooled_agents: Vec<String> = self
.available_agents
.iter()
.filter(|a| {
!is_agent_in_cooldown(a)
&& !is_agent_degraded(a)
&& self.agent_has_available_model_for_coding_tiers(a)
})
.cloned()
.collect();
if uncooled_agents.is_empty() {
return Err(AllAgentsCooledError::new("all agents".to_string(), None).into());
}
let llm_agents = uncooled_agents;
let pool = self.router_pool.clone();
let n = pool.len();
let start = self.pool_index;
let mut last_err: Option<anyhow::Error> = None;
let mut attempted_any = false;
for i in 0..n {
let idx = (start + i) % n;
let (agent, model) = &pool[idx];
let model_str = model.as_str();
if crate::engine::runner::response::is_model_in_cooldown(agent, model_str) {
tracing::debug!(agent, model = model_str, "pool entry on cooldown, skipping");
continue;
}
attempted_any = true;
let model_opt = if model_str.is_empty() {
None
} else {
Some(model_str)
};
match self
.llm_router
.route_with_llm_using(
task,
&llm_agents,
&self.config,
&mut self.last_agent,
repo,
agent,
model_opt,
)
.await
{
Ok(result) => {
self.advance_pool_index_after_attempt(idx, n);
tracing::debug!(
agent,
model = model_str,
pool_idx = idx,
"router LLM pool entry succeeded"
);
record_agent_success(agent, model_str).await;
return Ok(result);
}
Err(e) => {
let err_msg = e.to_string();
let is_timeout = err_msg.contains(TIMEOUT_PREFIX);
if is_timeout {
self.advance_pool_index_after_attempt(idx, n);
tracing::warn!(
agent,
model = model_str,
next_pool_idx = self.pool_index,
"router LLM pool entry timed out — recording cooldown, will retry next tick"
);
crate::engine::runner::response::record_model_failure(agent, model_str)
.await;
return Err(e);
} else {
tracing::warn!(
agent,
model = model_str,
error = %e,
"pool entry failed, recording cooldown and trying next"
);
crate::engine::runner::response::record_model_failure(agent, model_str)
.await;
}
last_err = Some(e);
self.advance_pool_index_after_attempt(idx, n);
}
}
}
if !attempted_any {
let now = chrono::Utc::now().timestamp();
let earliest = self.earliest_pool_cooldown();
if let Some(until) = earliest {
let remaining = until.saturating_sub(now);
tracing::warn!(
remaining_secs = remaining,
pool = ?pool,
"all router LLM pool entries on cooldown — will try fallback"
);
} else {
return Err(last_err
.unwrap_or_else(|| anyhow::anyhow!("all router LLM pool entries exhausted")));
}
}
let fallback = self.config.effective_fallback();
let (fb_agent, fb_model) = parse_pool_entry(&fallback);
let fb_model_str = fb_model.as_str();
let fallback_already_tried = pool.iter().any(|(a, m)| a == &fb_agent && m == &fb_model);
if !fallback_already_tried
&& !crate::engine::runner::response::is_model_in_cooldown(&fb_agent, fb_model_str)
{
tracing::info!(
agent = %fb_agent,
model = %fb_model_str,
"all pool entries exhausted — trying fallback router LLM"
);
let fb_model_opt = if fb_model_str.is_empty() {
None
} else {
Some(fb_model_str)
};
match self
.llm_router
.route_with_llm_using(
task,
&llm_agents,
&self.config,
&mut self.last_agent,
repo,
&fb_agent,
fb_model_opt,
)
.await
{
Ok(result) => {
record_agent_success(&fb_agent, fb_model_str).await;
return Ok(result);
}
Err(e) => {
let err_msg = e.to_string();
let is_timeout = err_msg.contains(TIMEOUT_PREFIX);
if is_timeout {
crate::engine::runner::response::record_model_failure(
&fb_agent,
fb_model_str,
)
.await;
tracing::warn!(
agent = %fb_agent,
model = %fb_model_str,
"fallback router LLM timed out — returning immediately to unblock tick loop"
);
return Err(e);
} else {
tracing::warn!(
agent = %fb_agent,
model = %fb_model_str,
error = %e,
"fallback router LLM also failed"
);
crate::engine::runner::response::record_model_failure(
&fb_agent,
fb_model_str,
)
.await;
}
last_err = Some(e);
}
}
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("all router LLM pool entries exhausted")))
}
pub fn record_rate_limit(&mut self, agent: &str) {
self.weights.record_rate_limit(agent);
}
pub fn record_success(&mut self, agent: &str) {
self.weights.record_success(agent);
}
pub fn tick_weight_recovery(&mut self) {
self.weights.tick_recovery();
}
pub async fn store_route_result(
&self,
task_id: &str,
result: &RouteResult,
store: &std::sync::Arc<TaskStore>,
repo: &str,
) -> anyhow::Result<()> {
if let Ok(Some(store_id)) = store.resolve_task_id(repo, task_id).await {
store
.set_fields(
store_id,
&[
("agent", serde_json::json!(result.agent)),
("complexity", serde_json::json!(result.complexity)),
("route_reason", serde_json::json!(result.reason)),
(
"model",
serde_json::json!(result.model.as_deref().unwrap_or("")),
),
],
)
.await?;
}
Ok(())
}
}
pub async fn get_route_result(
store: &std::sync::Arc<TaskStore>,
repo: &str,
task_id: &str,
) -> Result<RouteResult, RouteResultError> {
let store_id = store
.resolve_task_id(repo, task_id)
.await
.map_err(RouteResultError::Other)?
.ok_or_else(|| {
RouteResultError::Other(anyhow::anyhow!("task {task_id} not found in store"))
})?;
let task = store.get(store_id).await.map_err(RouteResultError::Other)?;
let agent = task
.agent
.clone()
.filter(|a| !a.is_empty())
.ok_or_else(|| RouteResultError::NoAgent {
task_id: task_id.to_string(),
})?;
let complexity = if task.complexity.is_empty() {
"medium".to_string()
} else {
task.complexity.clone()
};
let reason = task.route_reason.clone();
let model = task.model.clone().filter(|m| !m.is_empty());
let config = RouterConfig::from_config();
let model = model.filter(|m| {
let complexities = ["simple", "medium", "complex", "review"];
let all_pools: Vec<Vec<String>> = complexities
.iter()
.filter_map(|comp| config.model_pool_for_complexity(&agent, comp))
.collect();
if all_pools.is_empty() {
return true;
}
let valid = all_pools.iter().any(|pool| pool.contains(m));
if !valid {
tracing::warn!(
task_id,
agent,
model = %m,
"discarding stale model: not in agent's model pools"
);
}
valid
});
let original_model = task.model.clone().filter(|m| !m.is_empty());
if original_model.is_some() && model.is_none() {
let stale_model = original_model.as_deref().unwrap_or("unknown");
let stale_err = format!("stale model discarded: {stale_model} not in {agent} model pools");
if let Err(e) = store
.set_fields(
store_id,
&[
("model", serde_json::json!("")),
("last_error", serde_json::json!(stale_err)),
],
)
.await
{
tracing::warn!(task_id = %task_id, error = %e, "failed to clear stale model from store — will retry on next dispatch");
}
}
Ok(RouteResult {
agent,
model,
complexity,
estimate: 0,
reason,
profile: AgentProfile::default(),
selected_skills: vec![],
warning: None,
})
}
#[cfg(test)]
mod tests {
use super::config::DEFAULT_AGENTS;
use super::llm::LlmRouteResponse;
use super::strategies;
use super::weights::{
AgentWeights, RateLimitState, DEFAULT_WEIGHT, MIN_WEIGHT, RATE_LIMIT_DECAY, RECOVERY_DELAY,
};
use super::*;
use crate::backends::{ExternalId, ExternalTask};
use crate::engine::cooldown::{
clear_agent_degraded, is_agent_degraded, is_model_in_cooldown, mark_agent_degraded,
record_model_failure, set_model_cooldown,
};
use crate::store::TaskStore;
use serial_test::serial;
use std::time::{Duration, Instant};
impl Router {
fn parse_llm_response(&self, response: &str) -> anyhow::Result<LlmRouteResponse> {
self.llm_router.parse_llm_response("claude", response)
}
fn check_routing_sanity(
&self,
task: &ExternalTask,
agent: &str,
profile: &AgentProfile,
) -> Option<String> {
self.llm_router.check_routing_sanity(task, agent, profile)
}
}
async fn test_store() -> std::sync::Arc<TaskStore> {
std::sync::Arc::new(TaskStore::open_memory().await.unwrap())
}
fn create_test_task(id: &str, title: &str, labels: Vec<String>) -> ExternalTask {
ExternalTask {
id: ExternalId(id.to_string()),
title: title.to_string(),
body: "Test body".to_string(),
state: "open".to_string(),
labels,
author: "test".to_string(),
created_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: "2026-01-01T00:00:00Z".to_string(),
url: format!("https://github.com/test/test/issues/{id}"),
}
}
#[serial(cooldown_state)]
#[test]
fn extract_agent_from_labels() {
let mut config = RouterConfig::default();
config
.model_map
.entry("medium".to_string())
.or_default()
.insert("claude".to_string(), vec!["haiku".to_string()]);
assert_eq!(
strategies::extract_agent_from_labels(&config.agents, &["agent:claude".to_string()]),
Some("claude".to_string())
);
assert_eq!(
strategies::extract_agent_from_labels(&config.agents, &["agent:codex".to_string()]),
Some("codex".to_string())
);
assert_eq!(
strategies::extract_agent_from_labels(&config.agents, &["agent:opencode".to_string()]),
Some("opencode".to_string())
);
assert_eq!(
strategies::extract_agent_from_labels(&config.agents, &["status:new".to_string()]),
None
);
assert_eq!(
strategies::extract_agent_from_labels(&config.agents, &["agent:kimi".to_string()]),
Some("kimi".to_string())
);
assert_eq!(
strategies::extract_agent_from_labels(&config.agents, &["agent:minimax".to_string()]),
Some("minimax".to_string())
);
}
#[serial(cooldown_state)]
#[test]
fn default_agents_constant() {
assert_eq!(DEFAULT_AGENTS.len(), 7);
assert!(DEFAULT_AGENTS.contains(&"claude"));
assert!(DEFAULT_AGENTS.contains(&"kimi"));
assert!(DEFAULT_AGENTS.contains(&"minimax"));
assert!(DEFAULT_AGENTS.contains(&"glm"));
assert!(DEFAULT_AGENTS.contains(&"grok"));
}
#[serial(cooldown_state)]
#[test]
fn extract_complexity_from_labels() {
assert_eq!(
strategies::extract_complexity_from_labels(&["complexity:simple".to_string()]),
"simple"
);
assert_eq!(
strategies::extract_complexity_from_labels(&["complexity:medium".to_string()]),
"medium"
);
assert_eq!(
strategies::extract_complexity_from_labels(&["complexity:complex".to_string()]),
"complex"
);
assert_eq!(
strategies::extract_complexity_from_labels(&["status:new".to_string()]),
"medium"
);
}
#[serial(cooldown_state)]
#[test]
fn route_result_serialization() {
let result = RouteResult {
agent: "claude".to_string(),
model: Some("sonnet".to_string()),
complexity: "medium".to_string(),
estimate: 5,
reason: "test".to_string(),
profile: AgentProfile {
role: "backend".to_string(),
skills: vec!["rust".to_string()],
tools: vec!["git".to_string()],
constraints: vec![],
},
selected_skills: vec!["gh".to_string()],
warning: None,
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("claude"));
assert!(json.contains("sonnet"));
let deserialized: RouteResult = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.agent, "claude");
assert_eq!(deserialized.model, Some("sonnet".to_string()));
}
#[serial(cooldown_state)]
#[test]
fn router_config_default() {
let mut config = RouterConfig::default();
config
.model_map
.entry("medium".to_string())
.or_default()
.insert("claude".to_string(), vec!["haiku".to_string()]);
assert_eq!(config.mode, "round_robin");
assert_eq!(config.router_agent, "claude");
assert_eq!(config.router_model, "haiku");
assert_eq!(config.fallback_executor, "codex");
assert_eq!(config.max_route_attempts, 3);
assert!(!config.allowed_tools.is_empty());
assert!(!config.default_skills.is_empty());
assert_eq!(config.agents.len(), 7);
assert!(config.agents.contains(&"claude".to_string()));
assert!(config.agents.contains(&"codex".to_string()));
assert!(config.agents.contains(&"opencode".to_string()));
assert!(config.agents.contains(&"kimi".to_string()));
assert!(config.agents.contains(&"minimax".to_string()));
assert!(config.agents.contains(&"glm".to_string()));
assert!(config.agents.contains(&"grok".to_string()));
}
#[serial(cooldown_state)]
#[test]
fn model_map_lookup_returns_none_without_config() {
let config = RouterConfig::default();
assert!(config
.model_for_complexity("claude", "simple", "")
.is_none());
assert!(config
.model_for_complexity("claude", "medium", "")
.is_none());
assert!(config
.model_for_complexity("claude", "complex", "")
.is_none());
assert!(config
.model_for_complexity("opencode", "review", "")
.is_none());
}
#[serial(cooldown_state)]
#[test]
fn model_map_lookup_returns_configured_value() {
let mut config = RouterConfig::default();
config
.model_map
.entry("simple".to_string())
.or_default()
.insert("claude".to_string(), vec!["haiku".to_string()]);
assert_eq!(
config.model_for_complexity("claude", "simple", ""),
Some("haiku".to_string())
);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn model_pool_selection_skips_cooled() {
crate::engine::cooldown::reset_global_state().await;
use std::collections::HashMap;
let mut config = RouterConfig::default();
config
.model_map
.entry("simple".to_string())
.or_default()
.insert(
"opencode".to_string(),
vec!["model-a".to_string(), "model-b".to_string()],
);
let m = config.model_for_complexity("opencode", "simple", "task-1");
assert!(m == Some("model-a".to_string()) || m == Some("model-b".to_string()));
record_model_failure("opencode", "model-a").await;
assert!(is_model_in_cooldown("opencode", "model-a"));
for i in 0..20 {
let result = config.model_for_complexity("opencode", "simple", &i.to_string());
assert_eq!(
result,
Some("model-b".to_string()),
"expected model-b when model-a is cooled, got {result:?} for task_id={i}"
);
}
record_model_failure("opencode", "model-b").await;
let fallback = config.model_for_complexity("opencode", "simple", "task-fallback");
assert_eq!(fallback, None);
let _ = HashMap::<(), ()>::new(); }
#[serial(cooldown_state)]
#[test]
fn model_pool_single_string_backward_compat() {
let mut config = RouterConfig::default();
config
.model_map
.entry("simple".to_string())
.or_default()
.insert("claude".to_string(), vec!["haiku".to_string()]);
let m = config.model_for_complexity("claude", "simple", "any-task");
assert_eq!(m, Some("haiku".to_string()));
}
#[serial(cooldown_state)]
#[test]
fn parse_llm_response_direct_json() {
let config = RouterConfig::default();
let router = Router::new(config);
let response = r#"{
"executor": "claude",
"complexity": "complex",
"reason": "requires architecture analysis",
"profile": {
"role": "architect",
"skills": ["rust", "design"],
"tools": ["git", "rg"],
"constraints": []
},
"selected_skills": ["gh"]
}"#;
let parsed = router.parse_llm_response(response).unwrap();
assert_eq!(parsed.executor, "claude");
assert_eq!(parsed.complexity, "complex");
assert_eq!(parsed.reason, "requires architecture analysis");
assert_eq!(parsed.profile.role, "architect");
}
#[serial(cooldown_state)]
#[test]
fn parse_llm_response_markdown_fenced() {
let config = RouterConfig::default();
let router = Router::new(config);
let response = r#"Here's my analysis:
```json
{
"executor": "codex",
"complexity": "medium",
"reason": "coding task",
"profile": {
"role": "developer",
"skills": ["coding"],
"tools": [],
"constraints": []
},
"selected_skills": []
}
```
Hope that helps!"#;
let parsed = router.parse_llm_response(response).unwrap();
assert_eq!(parsed.executor, "codex");
assert_eq!(parsed.complexity, "medium");
}
#[serial(cooldown_state)]
#[test]
fn parse_llm_response_claude_envelope() {
let config = RouterConfig::default();
let router = Router::new(config);
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}}"#;
let parsed = router.parse_llm_response(response).unwrap();
assert_eq!(parsed.executor, "claude");
assert_eq!(parsed.complexity, "complex");
assert_eq!(parsed.reason, "requires deep analysis");
}
#[serial(cooldown_state)]
#[test]
fn parse_llm_response_claude_envelope_with_code_block() {
let config = RouterConfig::default();
let router = Router::new(config);
let inner = "```json\n{\"executor\":\"codex\",\"complexity\":\"simple\",\"reason\":\"simple fix\",\"profile\":{\"role\":\"developer\",\"skills\":[],\"tools\":[],\"constraints\":[]},\"selected_skills\":[]}\n```";
let envelope = serde_json::json!({
"type": "result",
"subtype": "success",
"is_error": false,
"result": inner,
"usage": {"input_tokens": 50, "output_tokens": 30}
});
let parsed = router.parse_llm_response(&envelope.to_string()).unwrap();
assert_eq!(parsed.executor, "codex");
assert_eq!(parsed.complexity, "simple");
}
#[serial(cooldown_state)]
#[test]
fn parse_llm_response_claude_envelope_error() {
let config = RouterConfig::default();
let router = Router::new(config);
let response = r#"{"type":"result","subtype":"error","is_error":true,"result":"rate limit exceeded","usage":{}}"#;
let err = router.parse_llm_response(response).unwrap_err();
assert!(err.to_string().contains("error"), "got: {err}");
}
#[serial(cooldown_state)]
#[test]
fn parse_llm_response_empty() {
let config = RouterConfig::default();
let router = Router::new(config);
let err = router.parse_llm_response("").unwrap_err();
assert!(err.to_string().contains("empty"), "got: {err}");
}
#[serial(cooldown_state)]
#[test]
fn parse_llm_response_claude_envelope_result_as_object() {
let config = RouterConfig::default();
let router = Router::new(config);
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}}"#;
let parsed = router.parse_llm_response(response);
assert!(
parsed.is_ok(),
"should handle result-as-object: {}",
parsed.unwrap_err()
);
assert_eq!(parsed.unwrap().executor, "claude");
}
#[serial(cooldown_state)]
#[test]
fn parse_llm_response_agent_instead_of_executor() {
let config = RouterConfig::default();
let router = Router::new(config);
let response = r#"{"agent":"claude","complexity":"medium","reason":"multi-file feature","profile":{"role":"developer","skills":[],"tools":[],"constraints":[]},"selected_skills":[]}"#;
let parsed = router.parse_llm_response(response);
assert!(
parsed.is_ok(),
"should accept 'agent' alias: {}",
parsed.unwrap_err()
);
assert_eq!(parsed.unwrap().executor, "claude");
}
#[serial(cooldown_state)]
#[test]
fn parse_llm_response_minimal_json() {
let config = RouterConfig::default();
let router = Router::new(config);
let response = r#"{"executor":"codex","complexity":"simple"}"#;
let parsed = router.parse_llm_response(response);
assert!(
parsed.is_ok(),
"should accept minimal JSON: {}",
parsed.unwrap_err()
);
assert_eq!(parsed.unwrap().executor, "codex");
}
#[serial(cooldown_state)]
#[test]
fn fixture_route_response_string() {
let config = RouterConfig::default();
let router = Router::new(config);
let response = include_str!("../../../tests/fixtures/route-response-string.json");
let parsed = router.parse_llm_response(response).unwrap();
assert_eq!(parsed.executor, "codex");
assert_eq!(parsed.complexity, "medium");
assert!(!parsed.reason.is_empty());
assert!(!parsed.profile.role.is_empty());
}
#[serial(cooldown_state)]
#[test]
fn fixture_route_response_object() {
let config = RouterConfig::default();
let router = Router::new(config);
let response = include_str!("../../../tests/fixtures/route-response-object.json");
let parsed = router.parse_llm_response(response).unwrap();
assert_eq!(parsed.executor, "claude");
assert_eq!(parsed.complexity, "complex");
}
#[serial(cooldown_state)]
#[test]
fn fixture_route_response_markdown() {
let config = RouterConfig::default();
let router = Router::new(config);
let response = include_str!("../../../tests/fixtures/route-response-markdown.json");
let parsed = router.parse_llm_response(response).unwrap();
assert_eq!(parsed.executor, "codex");
assert_eq!(parsed.complexity, "medium");
}
#[serial(cooldown_state)]
#[test]
fn parse_llm_response_claude_envelope_with_prose() {
let config = RouterConfig::default();
let router = Router::new(config);
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.";
let envelope = serde_json::json!({
"type": "result",
"subtype": "success",
"is_error": false,
"result": inner,
"usage": {"input_tokens": 100, "output_tokens": 80}
});
let parsed = router.parse_llm_response(&envelope.to_string());
assert!(
parsed.is_ok(),
"should handle prose + code block in envelope: {}",
parsed.unwrap_err()
);
assert_eq!(parsed.unwrap().executor, "codex");
}
#[serial(cooldown_state)]
#[tokio::test]
async fn route_round_robin_basic() {
crate::engine::cooldown::reset_global_state().await;
let config = RouterConfig {
mode: "round_robin".to_string(),
..Default::default()
};
let agents = vec!["claude".to_string(), "codex".to_string()];
let mut weights = AgentWeights::default();
weights.ensure_agents(&agents);
let router = Router {
config,
available_agents: agents,
weights,
llm_router: std::sync::Arc::new(LlmRouter::new()),
ollama_router: None,
rr_index: 0,
last_agent: None,
review_rr_index: 0,
router_pool: vec![],
pool_index: 0,
};
let task = create_test_task("1", "Test task", vec![]);
let result =
strategies::route_via_round_robin(&router.available_agents, &router.config, &task)
.unwrap();
assert_eq!(result.agent, "codex");
assert_eq!(result.reason, "round_robin (task 1 % 2 agents)");
}
#[serial(cooldown_state)]
#[tokio::test]
async fn route_uses_label_override() {
crate::engine::cooldown::reset_global_state().await;
let mut config = RouterConfig::default();
config
.model_map
.entry("medium".to_string())
.or_default()
.insert("claude".to_string(), vec!["haiku".to_string()]);
let agents = vec!["claude".to_string(), "codex".to_string()];
let mut weights = AgentWeights::default();
weights.ensure_agents(&agents);
let mut router = Router {
config,
available_agents: agents,
weights,
llm_router: std::sync::Arc::new(LlmRouter::new()),
ollama_router: None,
rr_index: 0,
last_agent: None,
review_rr_index: 0,
router_pool: vec![],
pool_index: 0,
};
let task = create_test_task("1", "Test", vec!["agent:claude".to_string()]);
let store = test_store().await;
let result = router.route(&task, &store, "test/repo").await.unwrap();
assert_eq!(result.agent, "claude");
assert!(result.reason.contains("label"));
}
#[serial(cooldown_state)]
#[test]
fn check_routing_sanity_warnings() {
let config = RouterConfig::default();
let router = Router::new(config);
let task = create_test_task("1", "Fix API", vec!["backend".to_string()]);
let profile = AgentProfile {
role: "general".to_string(),
skills: vec!["api".to_string()],
tools: vec![],
constraints: vec![],
};
let warning = router.check_routing_sanity(&task, "claude", &profile);
assert!(warning.is_some());
assert!(warning.unwrap().contains("backend"));
let task = create_test_task("1", "Update README", vec!["docs".to_string()]);
let warning = router.check_routing_sanity(&task, "codex", &profile);
assert!(warning.is_some());
assert!(warning.unwrap().contains("docs"));
let task = create_test_task("1", "Fix bug", vec!["bug".to_string()]);
let warning = router.check_routing_sanity(&task, "codex", &profile);
assert!(warning.is_none());
}
#[serial(cooldown_state)]
#[tokio::test]
async fn router_reload_preserves_structure() {
crate::engine::cooldown::reset_global_state().await;
let config = RouterConfig::default();
let agents = vec!["claude".to_string()];
let mut weights = AgentWeights::default();
weights.ensure_agents(&agents);
let mut router = Router {
config,
available_agents: agents,
weights,
llm_router: std::sync::Arc::new(LlmRouter::new()),
ollama_router: None,
rr_index: 0,
last_agent: None,
review_rr_index: 0,
router_pool: vec![],
pool_index: 0,
};
router.reload_async().await;
assert!(
router.config.mode == "llm"
|| router.config.mode == "round_robin"
|| router.config.mode == "local",
"mode should be 'llm', 'local', or 'round_robin', got '{}'",
router.config.mode
);
assert!(!router.config.fallback_executor.is_empty());
assert!(!router.config.allowed_tools.is_empty());
}
#[serial(cooldown_state)]
#[test]
fn rate_limit_state_defaults_to_full_weight() {
let state = RateLimitState::default();
assert_eq!(state.weight, DEFAULT_WEIGHT);
assert_eq!(state.consecutive_hits, 0);
assert!(state.last_limited_at.is_none());
assert!(!state.is_limited());
}
#[serial(cooldown_state)]
#[test]
fn rate_limit_state_decays_on_hit() {
let mut state = RateLimitState::default();
state.record_rate_limit();
assert!(state.weight < DEFAULT_WEIGHT);
assert_eq!(state.consecutive_hits, 1);
assert!(state.last_limited_at.is_some());
assert!(state.is_limited());
let expected = DEFAULT_WEIGHT * RATE_LIMIT_DECAY;
assert!((state.weight - expected).abs() < 1e-10);
}
#[serial(cooldown_state)]
#[test]
fn rate_limit_state_never_drops_below_min() {
let mut state = RateLimitState::default();
for _ in 0..100 {
state.record_rate_limit();
}
assert!(state.weight >= MIN_WEIGHT);
assert_eq!(state.consecutive_hits, 100);
}
#[serial(cooldown_state)]
#[test]
fn rate_limit_state_recovers_on_success() {
let mut state = RateLimitState::default();
state.record_rate_limit();
state.record_rate_limit();
let after_decay = state.weight;
state.record_success();
assert!(state.weight > after_decay);
assert_eq!(state.consecutive_hits, 0);
}
#[serial(cooldown_state)]
#[test]
fn rate_limit_state_success_caps_at_default() {
let mut state = RateLimitState::default();
state.record_success();
assert_eq!(state.weight, DEFAULT_WEIGHT);
}
#[serial(cooldown_state)]
#[test]
fn agent_weights_ensure_agents() {
let mut weights = AgentWeights::default();
let agents = vec!["claude".to_string(), "codex".to_string()];
weights.ensure_agents(&agents);
assert_eq!(weights.states.len(), 2);
assert_eq!(weights.get_weight("claude"), DEFAULT_WEIGHT);
assert_eq!(weights.get_weight("codex"), DEFAULT_WEIGHT);
}
#[serial(cooldown_state)]
#[test]
fn agent_weights_ensure_agents_preserves_existing() {
let mut weights = AgentWeights::default();
let agents = vec!["claude".to_string(), "codex".to_string()];
weights.ensure_agents(&agents);
weights.record_rate_limit("claude");
let claude_weight = weights.get_weight("claude");
assert!(claude_weight < DEFAULT_WEIGHT);
let agents2 = vec![
"claude".to_string(),
"codex".to_string(),
"opencode".to_string(),
];
weights.ensure_agents(&agents2);
assert_eq!(weights.get_weight("claude"), claude_weight);
assert_eq!(weights.get_weight("opencode"), DEFAULT_WEIGHT);
}
#[test]
fn default_missing_weight_uses_minimum_of_configured() {
use super::weights::default_missing_weight;
let mut configured = std::collections::HashMap::new();
configured.insert("claude".to_string(), 0.2);
configured.insert("codex".to_string(), 0.2);
configured.insert("opencode".to_string(), 0.2);
configured.insert("kimi".to_string(), 0.2);
assert_eq!(default_missing_weight(&configured), 0.2);
}
#[test]
fn default_missing_weight_falls_back_when_none_configured() {
use super::weights::default_missing_weight;
let configured = std::collections::HashMap::new();
assert_eq!(default_missing_weight(&configured), DEFAULT_WEIGHT);
}
#[serial(cooldown_state)]
#[test]
fn ensure_agents_with_weights_missing_agent_does_not_outrank_configured() {
let mut weights = AgentWeights::default();
let mut configured = std::collections::HashMap::new();
configured.insert("claude".to_string(), 0.2);
configured.insert("codex".to_string(), 0.2);
configured.insert("opencode".to_string(), 0.2);
configured.insert("kimi".to_string(), 0.2);
let agents = vec![
"claude".to_string(),
"codex".to_string(),
"opencode".to_string(),
"kimi".to_string(),
"minimax".to_string(),
];
weights.ensure_agents_with_weights(&agents, &configured);
assert_eq!(weights.get_weight("minimax"), 0.2);
assert_eq!(weights.get_weight("claude"), 0.2);
}
#[serial(cooldown_state)]
#[test]
fn agent_weights_record_rate_limit() {
let mut weights = AgentWeights::default();
weights.ensure_agents(&["claude".to_string(), "codex".to_string()]);
weights.record_rate_limit("claude");
assert!(weights.get_weight("claude") < DEFAULT_WEIGHT);
assert_eq!(weights.get_weight("codex"), DEFAULT_WEIGHT);
}
#[serial(cooldown_state)]
#[test]
fn agent_weights_record_success() {
let mut weights = AgentWeights::default();
weights.ensure_agents(&["claude".to_string()]);
weights.record_rate_limit("claude");
let after_limit = weights.get_weight("claude");
weights.record_success("claude");
assert!(weights.get_weight("claude") > after_limit);
}
#[serial(cooldown_state)]
#[test]
fn agent_weights_snapshot() {
let mut weights = AgentWeights::default();
weights.ensure_agents(&["claude".to_string(), "codex".to_string()]);
weights.record_rate_limit("claude");
let snap = weights.snapshot();
assert_eq!(snap.len(), 2);
assert_eq!(snap[0].0, "claude");
assert!(snap[0].1 < DEFAULT_WEIGHT);
assert_eq!(snap[0].2, 1);
assert_eq!(snap[1].0, "codex");
assert_eq!(snap[1].1, DEFAULT_WEIGHT);
assert_eq!(snap[1].2, 0); }
#[serial(cooldown_state)]
#[test]
fn agent_weights_weighted_select_favors_higher_weight() {
let mut weights = AgentWeights::default();
let agents = vec!["claude".to_string(), "codex".to_string()];
weights.ensure_agents(&agents);
for _ in 0..10 {
weights.record_rate_limit("claude");
}
let mut claude_count = 0;
let mut codex_count = 0;
for _ in 0..100 {
match weights.weighted_select(&agents, "task-1") {
Some(ref a) if a == "claude" => claude_count += 1,
Some(ref a) if a == "codex" => codex_count += 1,
_ => {}
}
}
assert!(
codex_count > claude_count,
"codex ({codex_count}) should get more selections than claude ({claude_count})"
);
}
#[serial(cooldown_state)]
#[test]
fn agent_weights_weighted_select_empty_returns_none() {
let weights = AgentWeights::default();
assert!(weights.weighted_select(&[], "task-1").is_none());
}
#[serial(cooldown_state)]
#[test]
fn agent_weights_batch_routing_produces_varied_selections() {
let mut weights = AgentWeights::default();
let agents = vec!["claude".to_string(), "codex".to_string()];
weights.ensure_agents(&agents);
let task_ids: Vec<String> = (1..=20).map(|i| format!("task-{i}")).collect();
let selections: Vec<String> = task_ids
.iter()
.filter_map(|id| weights.weighted_select(&agents, id))
.collect();
let claude_count = selections.iter().filter(|a| a.as_str() == "claude").count();
let codex_count = selections.iter().filter(|a| a.as_str() == "codex").count();
assert!(
claude_count > 0 && codex_count > 0,
"batch routing should distribute across agents; got claude={claude_count} codex={codex_count}"
);
}
#[serial(cooldown_state)]
#[test]
fn agent_weights_weighted_select_single_agent() {
let mut weights = AgentWeights::default();
let agents = vec!["claude".to_string()];
weights.ensure_agents(&agents);
let selected = weights.weighted_select(&agents, "task-1");
assert_eq!(selected, Some("claude".to_string()));
}
#[serial(cooldown_state)]
#[test]
fn agent_weights_tick_recovery_restores_weight() {
let mut weights = AgentWeights::default();
weights.ensure_agents(&["claude".to_string()]);
let state = weights.states.get_mut("claude").unwrap();
state.weight = 0.5;
state.last_limited_at = Some(Instant::now() - RECOVERY_DELAY - Duration::from_secs(1));
state.consecutive_hits = 2;
let before = weights.get_weight("claude");
weights.tick_recovery();
let after = weights.get_weight("claude");
assert!(after > before, "weight should increase after recovery tick");
}
#[serial(cooldown_state)]
#[test]
fn agent_weights_tick_recovery_clears_on_full_restore() {
let mut weights = AgentWeights::default();
weights.ensure_agents(&["claude".to_string()]);
let state = weights.states.get_mut("claude").unwrap();
state.weight = DEFAULT_WEIGHT - 0.01;
state.last_limited_at = Some(Instant::now() - RECOVERY_DELAY - Duration::from_secs(1));
state.consecutive_hits = 1;
weights.tick_recovery();
let state = weights.states.get("claude").unwrap();
assert_eq!(state.weight, DEFAULT_WEIGHT);
assert!(state.last_limited_at.is_none());
assert_eq!(state.consecutive_hits, 0);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn route_weighted_round_robin_basic() {
crate::engine::cooldown::reset_global_state().await;
let mut config = RouterConfig {
weighted_round_robin: true,
..Default::default()
};
config
.model_map
.entry("medium".to_string())
.or_default()
.insert("codex".to_string(), vec!["gpt-5.2".to_string()]);
let agents = vec!["claude".to_string(), "codex".to_string()];
let mut weights = AgentWeights::default();
weights.ensure_agents(&agents);
let mut router = Router {
config,
available_agents: agents,
weights,
llm_router: std::sync::Arc::new(LlmRouter::new()),
ollama_router: None,
rr_index: 0,
last_agent: None,
review_rr_index: 0,
router_pool: vec![],
pool_index: 0,
};
let task = create_test_task("1", "Test task", vec![]);
let store = test_store().await;
let result = router.route(&task, &store, "test/repo").await.unwrap();
assert!(result.reason.contains("weighted_round_robin"));
assert!(
result.agent == "claude" || result.agent == "codex",
"agent should be claude or codex, got '{}'",
result.agent
);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn route_weighted_round_robin_respects_label_override() {
crate::engine::cooldown::reset_global_state().await;
let mut config = RouterConfig {
weighted_round_robin: true,
..Default::default()
};
config
.model_map
.entry("medium".to_string())
.or_default()
.insert("codex".to_string(), vec!["gpt-5.2".to_string()]);
let agents = vec!["claude".to_string(), "codex".to_string()];
let mut weights = AgentWeights::default();
weights.ensure_agents(&agents);
let mut router = Router {
config,
available_agents: agents,
weights,
llm_router: std::sync::Arc::new(LlmRouter::new()),
ollama_router: None,
rr_index: 0,
last_agent: None,
review_rr_index: 0,
router_pool: vec![],
pool_index: 0,
};
let task = create_test_task("1", "Test task", vec!["agent:codex".to_string()]);
let store = test_store().await;
let result = router.route(&task, &store, "test/repo").await.unwrap();
assert_eq!(result.agent, "codex");
assert!(result.reason.contains("label"));
}
#[serial(cooldown_state)]
#[test]
fn router_config_weighted_round_robin_default_false() {
let config = RouterConfig::default();
assert!(!config.weighted_round_robin);
}
#[serial(cooldown_state)]
#[test]
fn router_record_and_recover_weights() {
let config = RouterConfig {
weighted_round_robin: true,
..Default::default()
};
let mut router = Router::new(config);
router.available_agents = vec!["claude".to_string(), "codex".to_string()];
router.weights.ensure_agents(&router.available_agents);
router.record_rate_limit("claude");
assert!(router.weights.get_weight("claude") < DEFAULT_WEIGHT);
assert_eq!(router.weights.get_weight("codex"), DEFAULT_WEIGHT);
router.record_success("claude");
let after_success = router.weights.get_weight("claude");
assert!(after_success > MIN_WEIGHT);
}
#[serial(cooldown_state)]
#[test]
fn review_rr_index_advances() {
let mut config = RouterConfig::default();
for agent in &["test_a", "test_b", "test_c"] {
config
.model_map
.entry("medium".to_string())
.or_default()
.insert((*agent).to_string(), vec!["test-model".to_string()]);
}
let agents = vec![
"test_a".to_string(),
"test_b".to_string(),
"test_c".to_string(),
];
let mut weights = AgentWeights::default();
weights.ensure_agents(&agents);
let mut router = Router {
config,
available_agents: agents,
weights,
llm_router: std::sync::Arc::new(LlmRouter::new()),
ollama_router: None,
rr_index: 0,
last_agent: None,
review_rr_index: 0,
router_pool: vec![],
pool_index: 0,
};
let a1 = router.next_round_robin_agent(&[], "medium").unwrap();
let a2 = router.next_round_robin_agent(&[], "medium").unwrap();
let a3 = router.next_round_robin_agent(&[], "medium").unwrap();
let mut seen = vec![a1, a2, a3];
seen.sort();
assert_eq!(seen, vec!["test_a", "test_b", "test_c"]);
}
#[serial(cooldown_state)]
#[test]
fn review_rr_fallback_with_cooled_agent() {
let mut config = RouterConfig::default();
for agent in &["test_a", "test_b", "test_c"] {
config
.model_map
.entry("medium".to_string())
.or_default()
.insert((*agent).to_string(), vec!["test-model".to_string()]);
}
let agents = vec!["test_a".to_string(), "test_b".to_string()];
let mut weights = AgentWeights::default();
weights.ensure_agents(&agents);
let mut router = Router {
config,
available_agents: agents,
weights,
llm_router: std::sync::Arc::new(LlmRouter::new()),
ollama_router: None,
rr_index: 0,
last_agent: None,
review_rr_index: 2,
router_pool: vec![],
pool_index: 0,
};
let result = router.next_round_robin_agent(&["test_a", "test_b"], "medium");
assert!(
result.is_some(),
"next_round_robin_agent must not return None when candidates is non-empty"
);
}
#[serial(cooldown_state)]
#[test]
fn review_rr_index_uses_candidates_len_not_available_len() {
let mut config = RouterConfig::default();
for agent in &["test_a", "test_b"] {
config
.model_map
.entry("medium".to_string())
.or_default()
.insert((*agent).to_string(), vec!["test-model".to_string()]);
}
let agents = vec![
"test_a".to_string(),
"test_b".to_string(),
"test_c".to_string(), ];
let mut weights = AgentWeights::default();
weights.ensure_agents(&agents);
let mut router = Router {
config,
available_agents: agents,
weights,
llm_router: std::sync::Arc::new(LlmRouter::new()),
ollama_router: None,
rr_index: 0,
last_agent: None,
review_rr_index: 0,
router_pool: vec![],
pool_index: 0,
};
let r1 = router.next_round_robin_agent(&[], "medium").unwrap();
assert_eq!(
router.review_rr_index, 1,
"index should be 1 after first call"
);
let r2 = router.next_round_robin_agent(&[], "medium").unwrap();
assert_eq!(
router.review_rr_index, 0,
"index should wrap to 0 after second call"
);
let r3 = router.next_round_robin_agent(&[], "medium").unwrap();
assert_eq!(
router.review_rr_index, 1,
"index should be 1 after third call"
);
assert_ne!(r1, r2, "consecutive calls should select different agents");
assert_eq!(r1, r3, "third call should match first (clean cycle)");
}
#[serial(cooldown_state)]
#[tokio::test]
async fn last_agent_tracks_routing() {
crate::engine::cooldown::reset_global_state().await;
let mut config = RouterConfig {
mode: "round_robin".to_string(),
..Default::default()
};
config
.model_map
.entry("medium".to_string())
.or_default()
.insert("test_x".to_string(), vec!["test-model-x".to_string()]);
config
.model_map
.entry("medium".to_string())
.or_default()
.insert("test_y".to_string(), vec!["test-model-y".to_string()]);
let agents = vec!["test_x".to_string(), "test_y".to_string()];
let mut weights = AgentWeights::default();
weights.ensure_agents(&agents);
let mut router = Router {
config,
available_agents: agents,
weights,
llm_router: std::sync::Arc::new(LlmRouter::new()),
ollama_router: None,
rr_index: 0,
last_agent: None,
review_rr_index: 0,
router_pool: vec![],
pool_index: 0,
};
assert!(router.last_agent.is_none());
let task = create_test_task("1", "Test routing", vec![]);
let store = test_store().await;
let result = router.route(&task, &store, "test/repo").await.unwrap();
assert!(router.last_agent.is_some());
assert_eq!(router.last_agent.as_deref(), Some(result.agent.as_str()));
}
#[serial(cooldown_state)]
#[test]
fn parse_pool_entry_splits_on_first_colon() {
let (agent, model) = super::parse_pool_entry("claude:haiku");
assert_eq!(agent, "claude");
assert_eq!(model, "haiku");
}
#[serial(cooldown_state)]
#[test]
fn parse_pool_entry_model_with_slash() {
let (agent, model) = super::parse_pool_entry("opencode:github-copilot/gpt-5-mini");
assert_eq!(agent, "opencode");
assert_eq!(model, "github-copilot/gpt-5-mini");
}
#[serial(cooldown_state)]
#[test]
fn parse_pool_entry_no_colon() {
let (agent, model) = super::parse_pool_entry("claude");
assert_eq!(agent, "claude");
assert_eq!(model, "");
}
#[serial(cooldown_state)]
#[test]
fn effective_pool_uses_configured_pool() {
let config = RouterConfig {
pool: vec!["kimi:k2p5".to_string(), "claude:haiku".to_string()],
..RouterConfig::default()
};
let pool = config.effective_pool();
assert_eq!(pool, vec!["kimi:k2p5", "claude:haiku"]);
}
#[serial(cooldown_state)]
#[test]
fn effective_pool_falls_back_to_router_agent_model() {
let config = RouterConfig::default();
let pool = config.effective_pool();
assert_eq!(pool.len(), 1);
assert!(pool[0].starts_with(&config.router_agent));
assert!(pool[0].contains(&config.router_model));
}
#[serial(cooldown_state)]
#[test]
fn effective_fallback_uses_configured_fallback() {
let config = RouterConfig {
fallback: "claude:haiku".to_string(),
..RouterConfig::default()
};
assert_eq!(config.effective_fallback(), "claude:haiku");
}
#[serial(cooldown_state)]
#[test]
fn effective_fallback_derives_from_router_agent_model() {
let config = RouterConfig::default();
let fallback = config.effective_fallback();
assert!(fallback.starts_with(&config.router_agent));
assert!(fallback.contains(&config.router_model));
}
#[serial(cooldown_state)]
#[test]
fn expand_pool_basic_entries() {
let config = RouterConfig {
pool: vec!["claude:haiku".to_string(), "kimi:k2p5".to_string()],
..RouterConfig::default()
};
let expanded = Router::expand_pool(&config);
assert_eq!(expanded.len(), 2);
assert_eq!(expanded[0], ("claude".to_string(), "haiku".to_string()));
assert_eq!(expanded[1], ("kimi".to_string(), "k2p5".to_string()));
}
#[serial(cooldown_state)]
#[test]
fn expand_pool_uses_effective_pool_when_pool_is_empty() {
let config = RouterConfig {
pool: vec![],
router_agent: "claude".to_string(),
router_model: "haiku".to_string(),
..RouterConfig::default()
};
let expanded = Router::expand_pool(&config);
assert_eq!(expanded.len(), 1);
assert_eq!(expanded[0], ("claude".to_string(), "haiku".to_string()));
}
#[serial(cooldown_state)]
#[test]
fn expand_pool_opencode_free_skipped_when_not_installed() {
if crate::cmd_cache::command_exists("opencode") {
return;
}
let config = RouterConfig {
pool: vec!["opencode:free".to_string()],
fallback: "claude:haiku".to_string(),
..RouterConfig::default()
};
let expanded = Router::expand_pool(&config);
assert_eq!(expanded.len(), 1);
assert_eq!(expanded[0].0, "claude");
assert_eq!(expanded[0].1, "haiku");
}
#[serial(cooldown_state)]
#[test]
fn expand_pool_single_entry_from_default() {
let config = RouterConfig::default();
let expanded = Router::expand_pool(&config);
assert!(
!expanded.is_empty(),
"pool must never be empty after expansion"
);
}
#[serial(cooldown_state)]
#[test]
fn pool_index_initializes_to_zero() {
let config = RouterConfig::default();
let router = Router::new(config);
assert_eq!(router.pool_index, 0);
}
#[serial(cooldown_state)]
#[test]
fn router_pool_non_empty_after_construction() {
let config = RouterConfig::default();
let router = Router::new(config);
assert!(!router.router_pool.is_empty());
}
#[serial(cooldown_state)]
#[tokio::test]
async fn reload_resets_pool_index() {
crate::engine::cooldown::reset_global_state().await;
let config = RouterConfig::default();
let mut router = Router::new(config);
router.pool_index = 5;
router.reload_async().await;
assert_eq!(router.pool_index, 0);
}
#[serial(cooldown_state)]
#[test]
fn pool_index_advances_after_failed_attempt() {
let mut router = Router::new(RouterConfig::default());
router.router_pool = vec![
("claude".to_string(), "haiku".to_string()),
("codex".to_string(), "gpt-5.2".to_string()),
(
"opencode".to_string(),
"github-copilot/gpt-5-mini".to_string(),
),
];
router.pool_index = 0;
router.advance_pool_index_after_attempt(0, router.router_pool.len());
assert_eq!(router.pool_index, 1);
}
#[serial(cooldown_state)]
#[test]
fn pool_index_wraps_after_last_attempt() {
let mut router = Router::new(RouterConfig::default());
router.router_pool = vec![
("claude".to_string(), "haiku".to_string()),
("codex".to_string(), "gpt-5.2".to_string()),
(
"opencode".to_string(),
"github-copilot/gpt-5-mini".to_string(),
),
];
router.pool_index = 2;
router.advance_pool_index_after_attempt(2, router.router_pool.len());
assert_eq!(router.pool_index, 0);
}
#[serial(cooldown_state)]
#[test]
fn earliest_pool_cooldown_returns_none_when_no_cooldowns() {
let router = Router::new(RouterConfig::default());
assert!(router.earliest_pool_cooldown().is_none());
}
#[serial(cooldown_state)]
#[tokio::test]
async fn earliest_pool_cooldown_returns_earliest_when_set() {
crate::engine::cooldown::reset_global_state().await;
let config = RouterConfig {
pool: vec![
"claude:haiku-4-5-20251001".to_string(),
"opencode:haiku-4-5-20251001".to_string(),
],
..Default::default()
};
let router = Router::new(config);
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();
assert!(earliest.is_some());
let now = chrono::Utc::now().timestamp();
let remaining = earliest.unwrap() - now;
assert!((295..=300).contains(&remaining));
}
#[serial(cooldown_state)]
#[tokio::test]
async fn earliest_pool_cooldown_skips_non_pool_entries() {
crate::engine::cooldown::reset_global_state().await;
let config = RouterConfig {
pool: vec![
"claude:pool-model-a".to_string(),
"opencode:pool-model-b".to_string(),
],
..Default::default()
};
let router = Router::new(config);
set_model_cooldown("claude", "opus", 999, "silence_detected").await;
set_model_cooldown("opencode", "pool-model-b", 300, "silence_detected").await;
let earliest = router.earliest_pool_cooldown();
assert!(earliest.is_some());
let now = chrono::Utc::now().timestamp();
let remaining = earliest.unwrap() - now;
assert!((295..=300).contains(&remaining));
}
#[serial(cooldown_state)]
#[test]
fn degraded_agent_excluded_from_routing() {
let mut config = RouterConfig::default();
config
.model_map
.entry("medium".to_string())
.or_default()
.insert(
"test_degraded_routing".to_string(),
vec!["model-a".to_string()],
);
let mut router = Router::new(config);
router.available_agents = vec!["test_degraded_routing".to_string()];
assert!(router.agent_is_routable("test_degraded_routing", "medium"));
mark_agent_degraded("test_degraded_routing");
assert!(!router.agent_is_routable("test_degraded_routing", "medium"));
assert!(
router.available_agents_for_complexity("medium").is_empty(),
"degraded agent should be excluded from available agents"
);
assert_eq!(router.healthy_agent_count("medium"), 0);
clear_agent_degraded("test_degraded_routing");
}
#[serial(cooldown_state)]
#[test]
fn healthy_agents_skips_degraded_but_keeps_healthy() {
let mut config = RouterConfig::default();
for agent in &["agent_healthy", "agent_degraded"] {
config
.model_map
.entry("medium".to_string())
.or_default()
.insert(agent.to_string(), vec!["model-x".to_string()]);
}
let mut router = Router::new(config);
router.available_agents = vec!["agent_healthy".to_string(), "agent_degraded".to_string()];
mark_agent_degraded("agent_degraded");
let candidates = router.available_agents_for_complexity("medium");
assert_eq!(candidates, vec!["agent_healthy".to_string()]);
assert_eq!(router.healthy_agent_count("medium"), 1);
clear_agent_degraded("agent_degraded");
}
#[serial(cooldown_state)]
#[tokio::test]
async fn refresh_health_marks_degraded_from_rate_limits() {
crate::engine::cooldown::reset_global_state().await;
let store = test_store().await;
let agent = "test_refresh_health_agent";
let mut config = RouterConfig::default();
config
.model_map
.entry("medium".to_string())
.or_default()
.insert(agent.to_string(), vec!["model-z".to_string()]);
let mut router = Router::new(config);
router.available_agents = vec![agent.to_string()];
for _ in 0..4 {
store
.record_rate_limit(agent, "rate_limit", None)
.await
.unwrap();
}
router.refresh_health(&store).await;
assert!(
is_agent_degraded(agent),
"agent should be degraded after exceeding rate limit threshold"
);
assert!(
!router.agent_is_routable(agent, "medium"),
"degraded agent should not be routable"
);
clear_agent_degraded(agent);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn agent_has_any_available_model_false_when_only_model_cooled() {
crate::engine::cooldown::reset_global_state().await;
let mut config = RouterConfig::default();
for tier in ["simple", "medium", "complex", "review"] {
config
.model_map
.entry(tier.to_string())
.or_default()
.insert(
"test_all_cooled".to_string(),
vec!["only-model".to_string()],
);
}
let mut router = Router::new(config);
router.available_agents = vec!["test_all_cooled".to_string()];
assert!(router.agent_has_any_available_model("test_all_cooled"));
record_model_failure("test_all_cooled", "only-model").await;
assert!(is_model_in_cooldown("test_all_cooled", "only-model"));
assert!(
!router.agent_has_any_available_model("test_all_cooled"),
"agent with its only model cooled across every tier should have no available model"
);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn llm_candidate_excludes_agent_with_all_models_cooled() {
crate::engine::cooldown::reset_global_state().await;
let mut config = RouterConfig::default();
for tier in ["simple", "medium", "complex", "review"] {
config
.model_map
.entry(tier.to_string())
.or_default()
.insert("agent_cooled_model".to_string(), vec!["opus".to_string()]);
config
.model_map
.get_mut(tier)
.unwrap()
.insert("agent_healthy".to_string(), vec!["model-x".to_string()]);
}
let mut router = Router::new(config);
router.available_agents = vec![
"agent_cooled_model".to_string(),
"agent_healthy".to_string(),
];
record_model_failure("agent_cooled_model", "opus").await;
assert!(is_model_in_cooldown("agent_cooled_model", "opus"));
assert!(!is_agent_in_cooldown("agent_cooled_model"));
let uncooled_agents: Vec<String> = router
.available_agents
.iter()
.filter(|a| {
!is_agent_in_cooldown(a)
&& !is_agent_degraded(a)
&& router.agent_has_available_model_for_coding_tiers(a)
})
.cloned()
.collect();
assert_eq!(
uncooled_agents,
vec!["agent_healthy".to_string()],
"agent whose only model is cooled must be excluded from the LLM candidate list"
);
}
#[serial(cooldown_state)]
#[tokio::test]
async fn llm_candidate_excludes_agent_with_only_simple_tier_uncooled() {
crate::engine::cooldown::reset_global_state().await;
let mut config = RouterConfig::default();
config
.model_map
.entry("simple".to_string())
.or_default()
.insert("minimax_like".to_string(), vec!["sonnet".to_string()]);
config
.model_map
.entry("medium".to_string())
.or_default()
.insert("minimax_like".to_string(), vec!["opus".to_string()]);
config
.model_map
.entry("complex".to_string())
.or_default()
.insert("minimax_like".to_string(), vec!["opus".to_string()]);
config
.model_map
.entry("review".to_string())
.or_default()
.insert("minimax_like".to_string(), vec!["opus".to_string()]);
for tier in ["simple", "medium", "complex", "review"] {
config
.model_map
.get_mut(tier)
.unwrap()
.insert("agent_healthy".to_string(), vec!["model-x".to_string()]);
}
let mut router = Router::new(config);
router.available_agents = vec!["minimax_like".to_string(), "agent_healthy".to_string()];
record_model_failure("minimax_like", "opus").await;
assert!(is_model_in_cooldown("minimax_like", "opus"));
assert!(!is_agent_in_cooldown("minimax_like"));
assert!(router.agent_has_any_available_model("minimax_like"));
assert!(!router.agent_has_available_model_for_coding_tiers("minimax_like"));
let uncooled_agents: Vec<String> = router
.available_agents
.iter()
.filter(|a| {
!is_agent_in_cooldown(a)
&& !is_agent_degraded(a)
&& router.agent_has_available_model_for_coding_tiers(a)
})
.cloned()
.collect();
assert_eq!(
uncooled_agents,
vec!["agent_healthy".to_string()],
"agent with only its simple tier uncooled must be excluded from the LLM candidate list"
);
}
}