use anyhow::Result;
use crate::agent::classifier::TaskCategory;
use crate::agent::selection::{AdviceReport, advise, caller_advice};
use crate::cli::advise_args::AdviseArgs;
use crate::store::Store;
use crate::types::DeclaredTaskProfile;
pub(crate) fn run(store: Option<&Store>, args: AdviseArgs) -> Result<()> {
crate::live_quota_refresh::refresh_stale_if_enabled();
let declared = DeclaredTaskProfile {
difficulty: args.difficulty,
budget: args.budget,
urgency: args.urgency,
rigor: args.rigor,
};
let kind_was_overridden = args.kind.is_some();
let report = build_report(
store,
&args.prompt,
declared,
args.kind,
args.team.as_deref(),
args.top,
args.caller_model.as_deref(),
);
if args.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print_human(&report, kind_was_overridden);
}
Ok(())
}
pub(crate) fn build_report(
store: Option<&Store>,
prompt: &str,
declared: DeclaredTaskProfile,
kind: Option<TaskCategory>,
team: Option<&str>,
top: usize,
caller_model: Option<&str>,
) -> AdviceReport {
let team = team.and_then(crate::team::resolve_team);
let model = crate::session::caller_model(caller_model);
let caller = crate::session::current_caller()
.and_then(|session| caller_advice(&session.kind, model.as_deref(), kind.unwrap_or_else(|| {
let normalized = prompt.trim().to_lowercase();
crate::agent::classifier::classify(prompt,
crate::agent::classifier::count_file_mentions(&normalized), prompt.chars().count()).category
})));
advise(prompt, declared, kind, team.as_ref(), store, top, caller)
}
fn print_human(report: &AdviceReport, kind_was_overridden: bool) {
let source = if kind_was_overridden { "declared" } else { "inferred" };
println!(
"Declared: {} / {} / {} / {} (kind: {}, {})",
report.declared.difficulty.label(),
report.declared.budget.label(),
report.declared.urgency.label(),
report.declared.rigor.label(),
report.inferred.kind.label(),
source,
);
print_recommendation(report);
if let Some(caller) = &report.caller {
println!(
"Caller: {} → {} pool (model {})",
caller.session, caller.provider, caller.model.as_deref().unwrap_or("unknown"),
);
}
leaderboard::print_candidates(report);
for (name, source) in &report.sources {
println!("Source {name}: {} | {} | {}", source.licence, source.attribution, source.url);
}
if !report.notes.is_empty() {
println!("Notes: {}", report.notes.join("; "));
}
}
fn recommended_quota_suffix(reason: &str) -> String {
reason
.split_once("; ")
.filter(|(_, rest)| rest.contains("held →"))
.map(|(_, rest)| format!(" quota: {rest}"))
.unwrap_or_default()
}
fn candidate_mark(reason: Option<&str>) -> String {
reason.map(|reason| format!(" [{reason}]")).unwrap_or_default()
}
fn cost_label(cost: Option<f64>) -> String {
cost.map(|value| format!("~${value:.2}"))
.unwrap_or_else(|| "cost unknown".to_string())
}
fn duration_label(seconds: Option<i64>) -> String {
seconds.map(|value| format!("~{}m", (value + 30) / 60))
.unwrap_or_else(|| "duration unknown".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn advice_payload_round_trips_through_json() {
let home = tempfile::tempdir().expect("temp aid home");
let _guard = crate::paths::AidHomeGuard::set(home.path());
let args = AdviseArgs {
prompt: "Refactor src/main.rs".to_string(),
difficulty: crate::types::TaskDifficulty::Complex,
budget: crate::types::TaskBudget::Premium,
urgency: crate::types::TaskUrgency::Urgent,
rigor: crate::types::TaskRigor::Critical,
kind: None,
team: None,
top: 5,
json: true,
dir: None,
caller_model: None,
};
let declared = DeclaredTaskProfile {
difficulty: args.difficulty,
budget: args.budget,
urgency: args.urgency,
rigor: args.rigor,
};
let report = advise(&args.prompt, declared, None, None, None, args.top, None);
let encoded = serde_json::to_value(&report).expect("serialize advice");
let decoded: AdviceReport = serde_json::from_value(encoded).expect("parse advice");
assert_eq!(decoded, report);
}
#[test]
fn complex_critical_surfaces_alternatives_with_shortfall_reasons() {
let home = tempfile::tempdir().expect("temp aid home");
let _guard = crate::paths::AidHomeGuard::set(home.path());
use crate::types::AgentKind;
let _fleet = crate::agent::DetectAgentsGuard::set(vec![
AgentKind::Codex, AgentKind::Droid, AgentKind::Claude, AgentKind::Cursor,
]);
crate::scores::test_support::seed_live();
crate::agent_config::save_agent_default_model("cursor", Some("openai/gpt-4o-mini"))
.expect("cursor model");
let declared = DeclaredTaskProfile {
difficulty: crate::types::TaskDifficulty::Complex,
budget: crate::types::TaskBudget::Premium,
urgency: crate::types::TaskUrgency::Normal,
rigor: crate::types::TaskRigor::Critical,
};
let report = advise(
"Refactor the scheduler across modules",
declared,
Some(crate::agent::classifier::TaskCategory::Refactoring),
None,
None,
5,
None,
);
assert!(report.recommended.is_some());
let eligible = report.candidates.iter().filter(|c| c.eligible).count();
assert!(eligible >= 2, "rigor must not collapse alternatives to one local agent");
let cursor = report.candidates.iter().find(|c| c.agent == "cursor");
if let Some(cursor) = cursor {
let reason = cursor.exclusion_reason.as_deref().unwrap_or("");
assert!(
reason.contains("base 0 < floor 8 for complex"),
"expected floor shortfall, got {reason:?}"
);
}
}
}
fn recommended_route(agent: &str, model: Option<String>) -> String {
match crate::types::AgentKind::parse_str(agent) {
Some(cli) if cli != crate::types::AgentKind::Custom => {
crate::types::Route::for_cli(cli).with_model(model).id()
}
_ => format!("{agent}/unknown/{}", model.as_deref().unwrap_or("-")),
}
}
#[cfg(test)]
mod recommended_route_tests {
use super::recommended_route;
#[test]
fn a_builtin_agent_resolves_to_its_provider() {
assert_eq!(
recommended_route("codex", Some("gpt-5.6".to_string())),
"codex/openai-chatgpt-plan/gpt-5.6"
);
}
#[test]
fn an_unpinned_model_is_shown_as_unpinned() {
assert_eq!(recommended_route("cursor", None), "cursor/cursor-subscription/-");
}
#[test]
fn a_custom_agent_keeps_the_dimension_and_admits_ignorance() {
assert_eq!(recommended_route("glm5", Some("z-ai/glm5".to_string())), "glm5/unknown/z-ai/glm5");
}
}
fn unrated_served_line(candidate: &crate::agent::selection::AdviceCandidate) -> Option<String> {
let model = candidate.model.as_deref()?;
(!candidate.unrated_served_models.is_empty()).then(|| format!(
" note: {} also serves newer unrated {} (advised {model}; not auto-selected)",
candidate.agent,
candidate.unrated_served_models.join(", "),
))
}
#[cfg(test)]
#[path = "advise_unrated_tests.rs"]
mod unrated_served_tests;
#[path = "advise_leaderboard.rs"]
mod leaderboard;
fn print_recommendation(report: &AdviceReport) {
if let Some(recommended) = &report.recommended {
let route = recommended_route(&recommended.agent, recommended.model.clone());
println!(
"Recommended: {} score {:.1} {} {}{}",
route,
recommended.score,
cost_label(recommended.est_cost_usd),
duration_label(recommended.est_duration_secs),
recommended_quota_suffix(&recommended.reason),
);
println!("Model: {}", crate::agent::run_model::model_label(
recommended.model.as_deref(), recommended.pinned, recommended.source,
));
} else {
println!("Recommended: none (no installed agents)");
}
}