use anyhow::Result;
use std::sync::Arc;
use crate::agent;
use crate::config::agent as agent_config;
use crate::config;
use crate::config::project::ProjectConfig;
use crate::routing::rate_limit;
use crate::store::Store;
use crate::types::AgentKind;
use crate::cost::usage;
use super::RunArgs;
#[path = "dispatch_resolve_held.rs"]
mod held;
#[path = "dispatch_model_info.rs"]
mod model_info;
#[path = "dispatch_run_model.rs"]
mod run_model;
pub(super) use held::maybe_insert_held_route_event;
pub(super) fn insert_gitbutler_setup_hint(store: &Store, task_id: &crate::types::TaskId) {
let _ = store.insert_event(&crate::types::TaskEvent {
task_id: task_id.clone(),
timestamp: chrono::Local::now(),
event_kind: crate::types::EventKind::Milestone,
detail: "Hint: run `but setup` from the main repo to enable GitButler integration for future tasks."
.to_string(),
metadata: None,
});
}
pub(super) struct AgentSetup {
pub agent_kind: AgentKind,
pub custom_agent_name: Option<String>,
pub agent_display_name: String,
pub effective_model: Option<String>,
pub budget_active: bool,
pub agent: Box<dyn agent::Agent>,
pub substituted_from: Option<(String, String)>,
}
pub(super) fn apply_project_defaults(args: &mut RunArgs, detected_project: Option<&ProjectConfig>) {
if let Some(project) = detected_project {
let mut defaults_applied = false;
if args.max_task_cost.is_none() {
args.max_task_cost = project.max_task_cost;
}
if args.team.is_none()
&& let Some(team) = project.team.as_ref() {
args.team = Some(team.clone());
defaults_applied = true;
}
if args.verify.is_none()
&& let Some(verify) = project.verify.as_ref() {
args.verify = Some(verify.clone());
defaults_applied = true;
}
if args.setup.is_none()
&& let Some(setup) = project.setup.as_ref() {
args.setup = Some(setup.clone());
defaults_applied = true;
}
args.remote_build = args.remote_build.take().or_else(|| crate::runtime::remote_build::project_default(project.remote_build.as_deref()));
if args.container.is_none()
&& let Some(container) = project.container.as_ref() {
args.container = Some(container.clone());
defaults_applied = true;
}
if !args.budget && project.budget.prefer_budget {
args.budget = true;
defaults_applied = true;
}
if args.no_audit {
args.audit = false;
} else if !args.audit_explicit && project.audit_auto() {
args.audit = true;
defaults_applied = true;
}
if defaults_applied {
aid_info!(
"[aid] Project '{}' defaults: team={}, verify={}, audit={}",
project.id,
args.team.as_deref().unwrap_or("None"),
args.verify.as_deref().unwrap_or("None"),
if args.audit { "on" } else { "off" },
);
}
}
}
pub(super) fn resolve_agent_setup(
store: &Arc<Store>,
args: &mut RunArgs,
project_id: Option<&str>,
) -> Result<AgentSetup> {
let (mut agent_kind, mut custom_agent_name) = if let Some(kind) = AgentKind::parse_str(&args.agent_name) {
(kind, None)
} else if agent::registry::custom_agent_exists(&args.agent_name) {
(AgentKind::Custom, Some(args.agent_name.clone()))
} else {
let custom = agent::registry::list_custom_agents();
let mut available = agent::routable_builtins()
.map(|kind| kind.as_str())
.collect::<Vec<_>>()
.join(", ");
for ca in &custom {
available.push_str(&format!(", {}", ca.id));
}
anyhow::bail!("Unknown agent '{}'. Available: {}", args.agent_name, available);
};
let resolved_agent_name = custom_agent_name
.as_deref()
.unwrap_or_else(|| agent_kind.as_str());
if agent_config::is_agent_disabled(resolved_agent_name) {
anyhow::bail!(
"Agent '{resolved_agent_name}' is disabled (enable with: aid agent config {resolved_agent_name} --enable)"
);
}
if args.dir.is_none()
&& args.worktree.is_none()
&& matches!(
agent_kind,
AgentKind::Codex
| AgentKind::Copilot
| AgentKind::Claude
| AgentKind::OpenCode
| AgentKind::Cursor
| AgentKind::Kilo
| AgentKind::MiMoCode
| AgentKind::Droid
| AgentKind::Grok
| AgentKind::Custom
)
&& std::path::Path::new(".git").exists()
{
args.dir = Some(".".to_string());
aid_info!("[aid] Auto-set --dir . (git repo detected)");
}
super::advice_route::validate_candidate_route(args, agent_kind)?;
let mut substituted_from: Option<(String, String)> = None;
if args.declared_urgency == Some(crate::types::TaskUrgency::Background)
&& held::background_keeps_hold(agent_kind, custom_agent_name.as_deref())
{
aid_warn!(
"[aid] {} is rate-limited; background urgency keeps this agent selected",
resolved_agent_name
);
} else if let Some(hold) =
rate_limit::dispatch_blocking_hold(&agent_kind, custom_agent_name.as_deref())
{
let original = custom_agent_name
.as_deref()
.unwrap_or_else(|| agent_kind.as_str())
.to_string();
let (next_kind, next_name) = held::skip_held_to_fallback(args, &original, &hold)?;
aid_warn!(
"[aid] {} is held ({}) — dispatching to {} instead. Use `aid config clear-limit {}` to clear.",
original, hold, next_name, original
);
agent_kind = next_kind;
custom_agent_name = (next_kind == AgentKind::Custom).then_some(next_name);
substituted_from = Some((original, hold));
}
held::warn_if_degraded(agent_kind, custom_agent_name.as_deref());
let custom_name = custom_agent_name.as_deref();
let cfg = config::load_config()?;
let budget_status = usage::check_budget_status_for_project(store, &cfg, project_id)?;
if budget_status.over_limit {
if let Some(msg) = budget_status.message {
anyhow::bail!("Budget limit exceeded:\n{msg}");
} else {
anyhow::bail!("Budget limit exceeded");
}
}
let auto_budget = if budget_status.near_limit && !cfg.selection.budget_mode {
if let Some(ref msg) = budget_status.message {
aid_warn!("[aid] Warning: {}\n[aid] Auto-enabling budget mode", msg);
}
true
} else {
false
};
let budget_active =
!args.force_default_model && (args.budget || auto_budget || cfg.selection.budget_mode);
let run_model = run_model::resolve(args, agent_kind, custom_name, auto_budget, &cfg.selection);
let effective_model = run_model.model.clone().filter(|_| run_model.pinned);
super::advice_route::validate_candidate_route(args, agent_kind)?;
let mut effective_model = if args.model_source == agent::model_validation::ModelSource::Advised {
effective_model
} else {
match agent::model_group::healthy_model_for(
agent_kind,
effective_model.as_deref(),
|group| {
rate_limit::is_group_rate_limited(&agent_kind, custom_agent_name.as_deref(), group)
},
) {
Some(replacement) => {
aid_warn!(
"[aid] {} model group exhausted; switching {} -> {}",
agent_kind.as_str(),
effective_model.as_deref().unwrap_or("(default)"),
replacement
);
args.model_source = agent::model_validation::ModelSource::AidResolved;
Some(replacement.to_string())
}
None => effective_model,
}
};
if (agent::model_group::model_group(agent_kind, effective_model.as_deref()).is_some()
|| !crate::routing::capacity::notes(&agent_kind, custom_agent_name.as_deref()).is_empty())
&& let Some(hold) = rate_limit::dispatch_blocking_hold_for_model(
&agent_kind,
custom_agent_name.as_deref(),
effective_model.as_deref(),
)
{
if crate::routing::availability::availability_for_group(
&agent_kind, custom_agent_name.as_deref(),
&crate::routing::capacity::group(effective_model.as_deref()),
).status == crate::routing::availability::RouteStatus::Held {
anyhow::bail!("{} model is held ({hold})", args.agent_name);
}
held::switch_model_held_route(
args,
&mut agent_kind,
&mut custom_agent_name,
&mut effective_model,
&mut substituted_from,
hold,
)?;
}
let agent: Box<dyn agent::Agent> = if agent_kind == AgentKind::Custom {
agent::registry::resolve_custom_agent(custom_agent_name.as_deref().unwrap_or(""))
.ok_or_else(|| anyhow::anyhow!("Custom agent '{}' not found in registry", args.agent_name))?
} else {
agent::get_agent(agent_kind)
};
let model_source = if args.model.is_some() || args.model_source == agent::model_validation::ModelSource::Advised { args.model_source } else { agent::model_validation::ModelSource::AidResolved };
args.model_source = model_source;
if let Some(ref model) = effective_model
&& !held::keep_aid_resolved_pin(substituted_from.as_ref(), model_source)
&& !agent::model_validation::validate_model_for_agent(agent.as_ref(), model, model_source)?
{
effective_model = None;
}
aid_info!("{}", model_info::model_selection_info(args, &run_model, effective_model.as_deref(), agent.as_ref()));
Ok(AgentSetup {
agent_kind,
custom_agent_name: custom_agent_name.clone(),
agent_display_name: custom_agent_name
.as_deref()
.unwrap_or_else(|| agent_kind.as_str())
.to_string(),
effective_model,
budget_active: budget_active && !args.force_default_model,
agent,
substituted_from,
})
}
#[cfg(test)] #[path = "dispatch_resolve_tests.rs"] mod tests;