use serde::{Deserialize, Serialize};
use crate::agent::classifier::TaskCategory;
use crate::config::team::TeamConfig;
use crate::types::{provider_for_cli, AgentKind, TaskBudget, TaskDifficulty};
use super::AdviceCandidate;
pub(crate) const WEAKER_ON_CALLER_POOL: &str = "weaker model on caller's pool";
#[derive(Debug, Default)]
pub(super) struct Exclusions {
reasons: Vec<String>,
codes: Vec<String>,
}
impl Exclusions {
pub(super) fn quota_and_billing(
&mut self, kind: AgentKind, model: Option<&str>, declared: crate::types::DeclaredTaskProfile,
breakdown: &mut super::ScoreBreakdown,
) -> (super::CandidateQuota, Option<crate::routing::quota::billing::Billing>) {
let quota = super::selection_quota::candidate_quota(kind, None, model);
if quota.status == "held" && needs_human_refusal(kind, model) {
let avail = crate::routing::availability::availability_for_model(&kind, None, model);
self.push("needs_human", avail.why);
}
let billing = crate::routing::quota::live::billing(&kind);
if let Some(reason) = billing.as_ref().and_then(|item| item.exclusion(declared)) {
self.push("billing", reason);
}
breakdown.billing = billing.as_ref().map_or(0.0, |item| item.penalty(declared));
if breakdown.billing != 0.0 { breakdown.total += breakdown.billing; }
(quota, billing)
}
pub(super) fn superseded_by_agy(&mut self, kind: AgentKind, agy_installed: bool) {
if kind == AgentKind::Gemini && agy_installed {
self.push("superseded_by_agy", "gemini individual tier superseded by agy (installed)".to_string());
}
}
pub(super) fn push(&mut self, code: &str, reason: String) {
self.codes.push(code.to_string());
self.reasons.push(reason);
}
pub(super) fn floor(
&mut self, base: Option<f64>, floor: i32, difficulty: TaskDifficulty, category: TaskCategory,
) -> Option<String> {
match base {
None => return Some(format!("unrated: no measured or model capability for {}", category.label())),
Some(base) if base < f64::from(floor) => self.push(
"below_floor",
format!("base {base} < floor {floor} for {}", difficulty.label()),
),
Some(_) => {}
}
None
}
pub(super) fn budget(&mut self, budget_ok: bool, budget: TaskBudget) {
if !budget_ok {
self.push("no_budget_model", format!("no model for budget {}", budget.label()));
}
}
pub(super) fn is_empty(&self) -> bool {
self.codes.is_empty()
}
pub(super) fn into_parts(self) -> (Option<String>, Vec<String>) {
let reason = (!self.reasons.is_empty()).then(|| self.reasons.join("; "));
(reason, self.codes)
}
}
fn needs_human_refusal(kind: AgentKind, model: Option<&str>) -> bool {
use crate::routing::rate_limit::{active_group_holds, get_rate_limit_info};
let group = crate::agent::model_group::model_group(kind, model);
let group_holds = active_group_holds(&kind, None).into_iter()
.filter(|(name, _)| group == Some(name.as_str())).map(|(_, info)| info);
get_rate_limit_info(&kind, None).into_iter().chain(group_holds).any(|info| {
info.needs_human && info.marker.lines().any(|line| human_action_line(kind, line))
})
}
fn human_action_line(kind: AgentKind, line: &str) -> bool {
use crate::routing::quota::channel::Channel;
use crate::routing::rate_limit::signatures::{match_quota_signature_for_agent, QuotaRecovery};
let message = line.strip_prefix("message: ").unwrap_or(line);
message.to_ascii_lowercase().contains("payment required")
|| match_quota_signature_for_agent(message, kind) == Some(QuotaRecovery::NeedsHuman)
|| [Channel::CliStream, Channel::CliStderr].into_iter().any(|channel| {
crate::routing::auth_marker::auth_failure_line(message, kind, channel).is_some()
})
}
pub(super) fn claude_allowed(kind: AgentKind, team: Option<&TeamConfig>) -> bool {
kind != AgentKind::Claude
|| team.is_some_and(|team| team.preferred_agents.iter().any(|name| name.eq_ignore_ascii_case("claude")))
}
impl AdviceCandidate {
pub(crate) fn launchable(&self, team: Option<&TeamConfig>) -> bool {
self.eligible && self.quota.status != "held"
&& self.kind().is_some_and(|kind| claude_allowed(kind, team))
}
pub(crate) fn kind(&self) -> Option<AgentKind> {
AgentKind::parse_str(&self.agent)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct CallerAdvice {
pub session: String,
pub agent: String,
pub provider: String,
pub model: Option<String>,
pub capability: Option<f64>,
}
pub(crate) fn caller_advice(session: &str, model: Option<&str>, category: TaskCategory) -> Option<CallerAdvice> {
let agent = crate::runtime::session::caller_agent(session)?;
let (provider, _) = provider_for_cli(agent);
let model = model.map(str::trim).filter(|value| !value.is_empty()).map(str::to_string);
let capability = model.as_deref().and_then(|name| {
crate::routing::scores::capability_score(agent, name, category)
});
Some(CallerAdvice {
session: session.to_string(),
agent: agent.as_str().to_string(),
provider: provider.as_str().to_string(),
model,
capability,
})
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(super) enum PoolVerdict {
Clear,
Weaker,
Demote,
}
pub(super) fn pool_verdict(caller: Option<&CallerAdvice>, kind: AgentKind, capability: Option<f64>) -> PoolVerdict {
let Some(caller) = caller else { return PoolVerdict::Clear };
let (provider, _) = provider_for_cli(kind);
if provider.is_unknown() || provider.as_str() != caller.provider {
return PoolVerdict::Clear;
}
match (caller.capability, capability) {
(Some(mine), Some(theirs)) if theirs < mine => PoolVerdict::Weaker,
(Some(_), Some(_)) => PoolVerdict::Clear,
_ => PoolVerdict::Demote,
}
}
pub(super) fn demotion_reason(caller: &CallerAdvice) -> String {
let model = caller.model.as_deref().unwrap_or("unknown");
format!(
"same pool as caller ({}); cannot compare with caller model {model}; ranked below other pools",
caller.provider
)
}
#[cfg(test)]
#[path = "selection_advice_gate_tests.rs"]
mod tests;