diff --git a/crates/agent-registry/src/lib.rs b/crates/agent-registry/src/lib.rs index 6579d19f6..ec624c701 100644 --- a/crates/agent-registry/src/lib.rs +++ b/crates/agent-registry/src/lib.rs @@ -10,5 +10,6 @@ pub use registry::{ clinepass_model_for_claude, headless_args, json_output_args, resume_arg, spec, }; pub use router::{ - RouteDecision, RouterConfig, RouterRule, RuleMatch, TaskContext, parse_router_config, route, + RouteDecision, RouterConfig, RouterRule, RuleMatch, TaskContext, model_for_task, + parse_router_config, route, }; diff --git a/crates/agent-registry/src/router.rs b/crates/agent-registry/src/router.rs index d13c1c795..39ffddb38 100644 --- a/crates/agent-registry/src/router.rs +++ b/crates/agent-registry/src/router.rs @@ -202,6 +202,22 @@ pub fn parse_router_config(text: &str) -> Result { Ok(RouterConfig { default, rules }) } +/// The model configured by the first `[router]` rule matching `task`, +/// independent of whether that rule's own `use_agents` is installed or even +/// relevant — for callers where the agent is already fixed (an explicit +/// assignment, or an operator's `--agent` flag) and only a rule's `model` +/// still needs to apply. Mirrors `route()`'s first-match-wins precedence: a +/// matching rule with no `model` set yields `None`, even if a later rule +/// would have one. +#[must_use] +pub fn model_for_task(task: &TaskContext, config: &RouterConfig) -> Option { + config + .rules + .iter() + .find(|rule| rule.when.matches(task)) + .and_then(|rule| rule.model.clone()) +} + /// Decides which agent should run `task`, or `None` if nothing local fits — /// the caller's cue to fall back (e.g. cede via the bridge). /// @@ -234,7 +250,7 @@ pub fn route( if let Some(agent) = task.assigned_agent { return Some(RouteDecision { agent, - model: None, + model: model_for_task(task, config), reason: "explicit assignment on task".to_string(), }); } @@ -340,6 +356,34 @@ mod tests { let decision = route(&task, &config, &[Agent::ClaudeCode], &mut no_rotation()).unwrap(); assert_eq!(decision.agent, Agent::Codex); assert_eq!(decision.reason, "explicit assignment on task"); + assert_eq!(decision.model, None); + } + + #[test] + fn explicit_assignment_still_picks_up_a_matching_rules_model() { + // Item #162: an assigned agent used to make `route()` bail with + // `model: None` before ever consulting `[router]` rules — which + // meant a rule's `model` could never reach a daemon-dispatched item + // (every daemon dispatch carries an explicit `assigned_agent`). + let task = TaskContext { + labels: vec!["docs".to_string()], + assigned_agent: Some(Agent::Cline), + ..Default::default() + }; + let config = RouterConfig { + default: None, + rules: vec![RouterRule { + model: Some("sonnet".to_string()), + ..rule(&["docs"], &[Agent::Opencode]) + }], + }; + // Opencode (the rule's `use`) isn't even installed — the matched + // rule's model should still apply, since the agent is already fixed + // by the assignment and the rule isn't being consulted for agent + // selection here. + let decision = route(&task, &config, &[Agent::ClaudeCode], &mut no_rotation()).unwrap(); + assert_eq!(decision.agent, Agent::Cline); + assert_eq!(decision.model.as_deref(), Some("sonnet")); } #[test] diff --git a/src/cli/work.rs b/src/cli/work.rs index 500085149..c5563bdcb 100644 --- a/src/cli/work.rs +++ b/src/cli/work.rs @@ -360,10 +360,23 @@ fn resolve_agent( ), String, > { + let task_context = + |assigned_agent: Option| agent_registry::TaskContext { + labels: labels.to_vec(), + kind: crate::mcp_server::item::parsed_kind(&item.metadata), + size: crate::mcp_server::item::parsed_size(&item.metadata), + repo: None, + assigned_agent, + role: role.map(str::to_string), + }; + if let Some(name) = explicit { - return agent_registry::agent_by_name(name) - .map(|agent| (agent, "explicit --agent flag".to_string(), None, None)) - .ok_or_else(|| format!("unknown agent: {name} — use `agentflare agents list`")); + let agent = agent_registry::agent_by_name(name) + .ok_or_else(|| format!("unknown agent: {name} — use `agentflare agents list`"))?; + // Agent is already fixed by the flag/dispatch — still consult + // `[router]` rules for a model (item #162). + let model = agent_registry::model_for_task(&task_context(None), config); + return Ok((agent, "explicit --agent flag".to_string(), None, model)); } let assigned_agent = item @@ -372,14 +385,7 @@ fn resolve_agent( .map(agentflare_backend::item::agent_part) .as_deref() .and_then(agent_registry::agent_by_name); - let task = agent_registry::TaskContext { - labels: labels.to_vec(), - kind: crate::mcp_server::item::parsed_kind(&item.metadata), - size: crate::mcp_server::item::parsed_size(&item.metadata), - repo: None, - assigned_agent, - role: role.map(str::to_string), - }; + let task = task_context(assigned_agent); let decision = agent_registry::route(&task, config, installed, rotation).ok_or_else(|| { "no --agent given, and no route decision (item has no assignee and no router \ rule matched) — pass --agent explicitly" diff --git a/src/cli/work_model_routing_tests.rs b/src/cli/work_model_routing_tests.rs index 6a41ff092..eb41ebc8c 100644 --- a/src/cli/work_model_routing_tests.rs +++ b/src/cli/work_model_routing_tests.rs @@ -131,3 +131,36 @@ fn resolve_dispatch_model_passes_through_a_non_claude_name_for_cline_unchanged() ); assert_eq!(model, Some("cline-pass/glm-5.3".to_string())); } + +#[test] +fn resolve_agent_explicit_flag_still_picks_up_a_matching_rules_model() { + // Item #162: this path used to always return `model: None` without + // ever consulting `[router]` rules. It's the path every normal + // daemon dispatch takes (the daemon always passes an explicit + // agent), so a rule's `model` never reached dispatch in practice. + let item = test_item(); + let config = agent_registry::RouterConfig { + default: None, + rules: vec![agent_registry::RouterRule { + when: agent_registry::RuleMatch { + labels: vec!["urgent".to_string()], + ..Default::default() + }, + use_agents: vec![agent_registry::Agent::Opencode], + rotate: false, + model: Some("sonnet".to_string()), + }], + }; + let (agent, _, _, model) = resolve_agent( + Some("cline"), + &item, + &["urgent".to_string()], + &config, + &[], + None, + &mut Default::default(), + ) + .unwrap(); + assert_eq!(agent, agent_registry::Agent::Cline); + assert_eq!(model.as_deref(), Some("sonnet")); +}