Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion crates/agent-registry/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
46 changes: 45 additions & 1 deletion crates/agent-registry/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,22 @@ pub fn parse_router_config(text: &str) -> Result<RouterConfig, String> {
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<String> {
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).
///
Expand Down Expand Up @@ -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(),
});
}
Expand Down Expand Up @@ -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]
Expand Down
28 changes: 17 additions & 11 deletions src/cli/work.rs
Original file line number Diff line number Diff line change
Expand Up @@ -360,10 +360,23 @@ fn resolve_agent(
),
String,
> {
let task_context =
|assigned_agent: Option<agent_registry::Agent>| 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
Expand All @@ -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"
Expand Down
33 changes: 33 additions & 0 deletions src/cli/work_model_routing_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}
Loading