use crate::subagent::{SubAgentDef, ToolPolicy};
use super::graph::TaskNode;
pub trait AgentRouter: Send + Sync {
fn route(&self, task: &TaskNode, available: &[SubAgentDef]) -> Option<String>;
}
pub struct RuleBasedRouter;
const TOOL_KEYWORDS: &[(&str, &str)] = &[
("write code", "Write"),
("implement", "Write"),
("create file", "Write"),
("edit", "Edit"),
("modify", "Edit"),
("read", "Read"),
("analyze", "Read"),
("review", "Read"),
("run test", "Bash"),
("execute", "Bash"),
("compile", "Bash"),
("build", "Bash"),
("search", "Grep"),
];
impl AgentRouter for RuleBasedRouter {
fn route(&self, task: &TaskNode, available: &[SubAgentDef]) -> Option<String> {
if available.is_empty() {
return None;
}
if let Some(ref hint) = task.agent_hint {
if available.iter().any(|d| d.name == *hint) {
return Some(hint.clone());
}
tracing::debug!(
task_id = %task.id,
hint = %hint,
"agent_hint not found in available agents, falling back to tool matching"
);
}
let desc_lower = task.description.to_lowercase();
let mut best_match: Option<(&SubAgentDef, usize)> = None;
for def in available {
let score = TOOL_KEYWORDS
.iter()
.filter(|(keyword, tool_name)| {
desc_lower.contains(*keyword) && agent_has_tool(def, tool_name)
})
.count();
if score > 0 && best_match.as_ref().is_none_or(|(_, best)| score > *best) {
best_match = Some((def, score));
}
}
if let Some((def, _)) = best_match {
return Some(def.name.clone());
}
Some(available[0].name.clone())
}
}
fn agent_has_tool(def: &SubAgentDef, tool_name: &str) -> bool {
if def.disallowed_tools.iter().any(|t| t == tool_name) {
return false;
}
match &def.tools {
ToolPolicy::AllowList(allowed) => allowed.iter().any(|t| t == tool_name),
ToolPolicy::DenyList(denied) => !denied.iter().any(|t| t == tool_name),
ToolPolicy::InheritAll => true,
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::default_trait_access)]
use super::*;
use crate::orchestration::graph::{TaskId, TaskNode, TaskStatus};
use crate::subagent::def::{SkillFilter, SubAgentPermissions, ToolPolicy};
fn make_task(id: u32, desc: &str, hint: Option<&str>) -> TaskNode {
TaskNode {
id: TaskId(id),
title: format!("task-{id}"),
description: desc.to_string(),
agent_hint: hint.map(str::to_string),
status: TaskStatus::Pending,
depends_on: vec![],
result: None,
assigned_agent: None,
retry_count: 0,
failure_strategy: None,
max_retries: None,
}
}
fn make_def(name: &str, tools: ToolPolicy) -> SubAgentDef {
SubAgentDef {
name: name.to_string(),
description: format!("{name} agent"),
model: None,
tools,
disallowed_tools: vec![],
permissions: SubAgentPermissions::default(),
skills: SkillFilter::default(),
system_prompt: String::new(),
hooks: crate::subagent::SubagentHooks::default(),
memory: None,
source: None,
file_path: None,
}
}
fn make_def_with_disallowed(
name: &str,
tools: ToolPolicy,
disallowed: Vec<String>,
) -> SubAgentDef {
SubAgentDef {
name: name.to_string(),
description: format!("{name} agent"),
model: None,
tools,
disallowed_tools: disallowed,
permissions: SubAgentPermissions::default(),
skills: SkillFilter::default(),
system_prompt: String::new(),
hooks: crate::subagent::SubagentHooks::default(),
memory: None,
source: None,
file_path: None,
}
}
#[test]
fn test_route_agent_hint_match() {
let task = make_task(0, "do something", Some("specialist"));
let available = vec![
make_def("generalist", ToolPolicy::InheritAll),
make_def("specialist", ToolPolicy::InheritAll),
];
let router = RuleBasedRouter;
let result = router.route(&task, &available);
assert_eq!(result.as_deref(), Some("specialist"));
}
#[test]
fn test_route_agent_hint_not_found_fallback() {
let task = make_task(0, "do something simple", Some("missing-agent"));
let available = vec![make_def("worker", ToolPolicy::InheritAll)];
let router = RuleBasedRouter;
let result = router.route(&task, &available);
assert_eq!(result.as_deref(), Some("worker"));
}
#[test]
fn test_route_tool_matching() {
let task = make_task(0, "implement the new feature by writing code", None);
let available = vec![
make_def(
"readonly-agent",
ToolPolicy::AllowList(vec!["Read".to_string()]),
),
make_def(
"writer-agent",
ToolPolicy::AllowList(vec!["Write".to_string(), "Edit".to_string()]),
),
];
let router = RuleBasedRouter;
let result = router.route(&task, &available);
assert_eq!(result.as_deref(), Some("writer-agent"));
}
#[test]
fn test_route_fallback_first_available() {
let task = make_task(0, "xyz123 abstract task", None);
let available = vec![
make_def("alpha", ToolPolicy::InheritAll),
make_def("beta", ToolPolicy::InheritAll),
];
let router = RuleBasedRouter;
let result = router.route(&task, &available);
assert_eq!(result.as_deref(), Some("alpha"));
}
#[test]
fn test_route_empty_returns_none() {
let task = make_task(0, "do something", None);
let router = RuleBasedRouter;
let result = router.route(&task, &[]);
assert!(result.is_none());
}
#[test]
fn test_agent_has_tool_allow_list() {
let def = make_def(
"a",
ToolPolicy::AllowList(vec!["Read".to_string(), "Write".to_string()]),
);
assert!(agent_has_tool(&def, "Read"));
assert!(agent_has_tool(&def, "Write"));
assert!(!agent_has_tool(&def, "Bash"));
}
#[test]
fn test_agent_has_tool_deny_list() {
let def = make_def("a", ToolPolicy::DenyList(vec!["Bash".to_string()]));
assert!(agent_has_tool(&def, "Read"));
assert!(agent_has_tool(&def, "Write"));
assert!(!agent_has_tool(&def, "Bash"));
}
#[test]
fn test_agent_has_tool_inherit_all() {
let def = make_def("a", ToolPolicy::InheritAll);
assert!(agent_has_tool(&def, "Read"));
assert!(agent_has_tool(&def, "Bash"));
assert!(agent_has_tool(&def, "AnythingGoes"));
}
#[test]
fn test_agent_has_tool_disallowed_wins_over_allow_list() {
let def = make_def_with_disallowed(
"a",
ToolPolicy::AllowList(vec!["Read".to_string(), "Bash".to_string()]),
vec!["Bash".to_string()],
);
assert!(agent_has_tool(&def, "Read"));
assert!(!agent_has_tool(&def, "Bash"), "disallowed_tools must win");
}
#[test]
fn test_agent_has_tool_disallowed_wins_over_inherit_all() {
let def = make_def_with_disallowed(
"a",
ToolPolicy::InheritAll,
vec!["DangerousTool".to_string()],
);
assert!(agent_has_tool(&def, "Read"));
assert!(!agent_has_tool(&def, "DangerousTool"));
}
}