1use crate::subagent::{SubAgentDef, ToolPolicy};
7
8use super::graph::TaskNode;
9
10pub trait AgentRouter: Send + Sync {
12 fn route(&self, task: &TaskNode, available: &[SubAgentDef]) -> Option<String>;
16}
17
18pub struct RuleBasedRouter;
37
38const TOOL_KEYWORDS: &[(&str, &str)] = &[
43 ("write code", "Write"),
44 ("implement", "Write"),
45 ("create file", "Write"),
46 ("edit", "Edit"),
47 ("modify", "Edit"),
48 ("read", "Read"),
49 ("analyze", "Read"),
50 ("review", "Read"),
51 ("run test", "Bash"),
52 ("execute", "Bash"),
53 ("compile", "Bash"),
54 ("build", "Bash"),
55 ("search", "Grep"),
56];
57
58impl AgentRouter for RuleBasedRouter {
59 fn route(&self, task: &TaskNode, available: &[SubAgentDef]) -> Option<String> {
60 if available.is_empty() {
61 return None;
62 }
63
64 if let Some(ref hint) = task.agent_hint {
66 if available.iter().any(|d| d.name == *hint) {
67 return Some(hint.clone());
68 }
69 tracing::debug!(
70 task_id = %task.id,
71 hint = %hint,
72 "agent_hint not found in available agents, falling back to tool matching"
73 );
74 }
75
76 let desc_lower = task.description.to_lowercase();
78 let mut best_match: Option<(&SubAgentDef, usize)> = None;
79
80 for def in available {
81 let score = TOOL_KEYWORDS
82 .iter()
83 .filter(|(keyword, tool_name)| {
84 desc_lower.contains(*keyword) && agent_has_tool(def, tool_name)
85 })
86 .count();
87
88 if score > 0 && best_match.as_ref().is_none_or(|(_, best)| score > *best) {
89 best_match = Some((def, score));
90 }
91 }
92
93 if let Some((def, _)) = best_match {
94 return Some(def.name.clone());
95 }
96
97 Some(available[0].name.clone())
99 }
100}
101
102fn agent_has_tool(def: &SubAgentDef, tool_name: &str) -> bool {
104 if def.disallowed_tools.iter().any(|t| t == tool_name) {
106 return false;
107 }
108
109 match &def.tools {
110 ToolPolicy::AllowList(allowed) => allowed.iter().any(|t| t == tool_name),
111 ToolPolicy::DenyList(denied) => !denied.iter().any(|t| t == tool_name),
112 ToolPolicy::InheritAll => true,
113 }
114}
115
116#[cfg(test)]
117mod tests {
118 #![allow(clippy::default_trait_access)]
119
120 use super::*;
121 use crate::orchestration::graph::{TaskId, TaskNode, TaskStatus};
122 use crate::subagent::def::{SkillFilter, SubAgentPermissions, ToolPolicy};
123
124 fn make_task(id: u32, desc: &str, hint: Option<&str>) -> TaskNode {
125 TaskNode {
126 id: TaskId(id),
127 title: format!("task-{id}"),
128 description: desc.to_string(),
129 agent_hint: hint.map(str::to_string),
130 status: TaskStatus::Pending,
131 depends_on: vec![],
132 result: None,
133 assigned_agent: None,
134 retry_count: 0,
135 failure_strategy: None,
136 max_retries: None,
137 }
138 }
139
140 fn make_def(name: &str, tools: ToolPolicy) -> SubAgentDef {
141 SubAgentDef {
142 name: name.to_string(),
143 description: format!("{name} agent"),
144 model: None,
145 tools,
146 disallowed_tools: vec![],
147 permissions: SubAgentPermissions::default(),
148 skills: SkillFilter::default(),
149 system_prompt: String::new(),
150 hooks: crate::subagent::SubagentHooks::default(),
151 memory: None,
152 source: None,
153 file_path: None,
154 }
155 }
156
157 fn make_def_with_disallowed(
158 name: &str,
159 tools: ToolPolicy,
160 disallowed: Vec<String>,
161 ) -> SubAgentDef {
162 SubAgentDef {
163 name: name.to_string(),
164 description: format!("{name} agent"),
165 model: None,
166 tools,
167 disallowed_tools: disallowed,
168 permissions: SubAgentPermissions::default(),
169 skills: SkillFilter::default(),
170 system_prompt: String::new(),
171 hooks: crate::subagent::SubagentHooks::default(),
172 memory: None,
173 source: None,
174 file_path: None,
175 }
176 }
177
178 #[test]
181 fn test_route_agent_hint_match() {
182 let task = make_task(0, "do something", Some("specialist"));
183 let available = vec![
184 make_def("generalist", ToolPolicy::InheritAll),
185 make_def("specialist", ToolPolicy::InheritAll),
186 ];
187 let router = RuleBasedRouter;
188 let result = router.route(&task, &available);
189 assert_eq!(result.as_deref(), Some("specialist"));
190 }
191
192 #[test]
193 fn test_route_agent_hint_not_found_fallback() {
194 let task = make_task(0, "do something simple", Some("missing-agent"));
195 let available = vec![make_def("worker", ToolPolicy::InheritAll)];
196 let router = RuleBasedRouter;
197 let result = router.route(&task, &available);
199 assert_eq!(result.as_deref(), Some("worker"));
200 }
201
202 #[test]
203 fn test_route_tool_matching() {
204 let task = make_task(0, "implement the new feature by writing code", None);
205 let available = vec![
206 make_def(
207 "readonly-agent",
208 ToolPolicy::AllowList(vec!["Read".to_string()]),
209 ),
210 make_def(
211 "writer-agent",
212 ToolPolicy::AllowList(vec!["Write".to_string(), "Edit".to_string()]),
213 ),
214 ];
215 let router = RuleBasedRouter;
216 let result = router.route(&task, &available);
217 assert_eq!(result.as_deref(), Some("writer-agent"));
219 }
220
221 #[test]
222 fn test_route_fallback_first_available() {
223 let task = make_task(0, "xyz123 abstract task", None);
225 let available = vec![
226 make_def("alpha", ToolPolicy::InheritAll),
227 make_def("beta", ToolPolicy::InheritAll),
228 ];
229 let router = RuleBasedRouter;
230 let result = router.route(&task, &available);
231 assert_eq!(result.as_deref(), Some("alpha"));
232 }
233
234 #[test]
235 fn test_route_empty_returns_none() {
236 let task = make_task(0, "do something", None);
237 let router = RuleBasedRouter;
238 let result = router.route(&task, &[]);
239 assert!(result.is_none());
240 }
241
242 #[test]
245 fn test_agent_has_tool_allow_list() {
246 let def = make_def(
247 "a",
248 ToolPolicy::AllowList(vec!["Read".to_string(), "Write".to_string()]),
249 );
250 assert!(agent_has_tool(&def, "Read"));
251 assert!(agent_has_tool(&def, "Write"));
252 assert!(!agent_has_tool(&def, "Bash"));
253 }
254
255 #[test]
256 fn test_agent_has_tool_deny_list() {
257 let def = make_def("a", ToolPolicy::DenyList(vec!["Bash".to_string()]));
258 assert!(agent_has_tool(&def, "Read"));
259 assert!(agent_has_tool(&def, "Write"));
260 assert!(!agent_has_tool(&def, "Bash"));
261 }
262
263 #[test]
264 fn test_agent_has_tool_inherit_all() {
265 let def = make_def("a", ToolPolicy::InheritAll);
266 assert!(agent_has_tool(&def, "Read"));
267 assert!(agent_has_tool(&def, "Bash"));
268 assert!(agent_has_tool(&def, "AnythingGoes"));
269 }
270
271 #[test]
272 fn test_agent_has_tool_disallowed_wins_over_allow_list() {
273 let def = make_def_with_disallowed(
275 "a",
276 ToolPolicy::AllowList(vec!["Read".to_string(), "Bash".to_string()]),
277 vec!["Bash".to_string()],
278 );
279 assert!(agent_has_tool(&def, "Read"));
280 assert!(!agent_has_tool(&def, "Bash"), "disallowed_tools must win");
281 }
282
283 #[test]
284 fn test_agent_has_tool_disallowed_wins_over_inherit_all() {
285 let def = make_def_with_disallowed(
286 "a",
287 ToolPolicy::InheritAll,
288 vec!["DangerousTool".to_string()],
289 );
290 assert!(agent_has_tool(&def, "Read"));
291 assert!(!agent_has_tool(&def, "DangerousTool"));
292 }
293}