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 use super::*;
119 use crate::orchestration::graph::{TaskId, TaskNode, TaskStatus};
120 use crate::subagent::def::{SkillFilter, SubAgentPermissions, ToolPolicy};
121
122 fn make_task(id: u32, desc: &str, hint: Option<&str>) -> TaskNode {
123 TaskNode {
124 id: TaskId(id),
125 title: format!("task-{id}"),
126 description: desc.to_string(),
127 agent_hint: hint.map(str::to_string),
128 status: TaskStatus::Pending,
129 depends_on: vec![],
130 result: None,
131 assigned_agent: None,
132 retry_count: 0,
133 failure_strategy: None,
134 max_retries: None,
135 }
136 }
137
138 fn make_def(name: &str, tools: ToolPolicy) -> SubAgentDef {
139 SubAgentDef {
140 name: name.to_string(),
141 description: format!("{name} agent"),
142 model: None,
143 tools,
144 disallowed_tools: vec![],
145 permissions: SubAgentPermissions::default(),
146 skills: SkillFilter::default(),
147 system_prompt: String::new(),
148 hooks: Default::default(),
149 memory: None,
150 source: None,
151 file_path: None,
152 }
153 }
154
155 fn make_def_with_disallowed(
156 name: &str,
157 tools: ToolPolicy,
158 disallowed: Vec<String>,
159 ) -> SubAgentDef {
160 SubAgentDef {
161 name: name.to_string(),
162 description: format!("{name} agent"),
163 model: None,
164 tools,
165 disallowed_tools: disallowed,
166 permissions: SubAgentPermissions::default(),
167 skills: SkillFilter::default(),
168 system_prompt: String::new(),
169 hooks: Default::default(),
170 memory: None,
171 source: None,
172 file_path: None,
173 }
174 }
175
176 #[test]
179 fn test_route_agent_hint_match() {
180 let task = make_task(0, "do something", Some("specialist"));
181 let available = vec![
182 make_def("generalist", ToolPolicy::InheritAll),
183 make_def("specialist", ToolPolicy::InheritAll),
184 ];
185 let router = RuleBasedRouter;
186 let result = router.route(&task, &available);
187 assert_eq!(result.as_deref(), Some("specialist"));
188 }
189
190 #[test]
191 fn test_route_agent_hint_not_found_fallback() {
192 let task = make_task(0, "do something simple", Some("missing-agent"));
193 let available = vec![make_def("worker", ToolPolicy::InheritAll)];
194 let router = RuleBasedRouter;
195 let result = router.route(&task, &available);
197 assert_eq!(result.as_deref(), Some("worker"));
198 }
199
200 #[test]
201 fn test_route_tool_matching() {
202 let task = make_task(0, "implement the new feature by writing code", None);
203 let available = vec![
204 make_def(
205 "readonly-agent",
206 ToolPolicy::AllowList(vec!["Read".to_string()]),
207 ),
208 make_def(
209 "writer-agent",
210 ToolPolicy::AllowList(vec!["Write".to_string(), "Edit".to_string()]),
211 ),
212 ];
213 let router = RuleBasedRouter;
214 let result = router.route(&task, &available);
215 assert_eq!(result.as_deref(), Some("writer-agent"));
217 }
218
219 #[test]
220 fn test_route_fallback_first_available() {
221 let task = make_task(0, "xyz123 abstract task", None);
223 let available = vec![
224 make_def("alpha", ToolPolicy::InheritAll),
225 make_def("beta", ToolPolicy::InheritAll),
226 ];
227 let router = RuleBasedRouter;
228 let result = router.route(&task, &available);
229 assert_eq!(result.as_deref(), Some("alpha"));
230 }
231
232 #[test]
233 fn test_route_empty_returns_none() {
234 let task = make_task(0, "do something", None);
235 let router = RuleBasedRouter;
236 let result = router.route(&task, &[]);
237 assert!(result.is_none());
238 }
239
240 #[test]
243 fn test_agent_has_tool_allow_list() {
244 let def = make_def(
245 "a",
246 ToolPolicy::AllowList(vec!["Read".to_string(), "Write".to_string()]),
247 );
248 assert!(agent_has_tool(&def, "Read"));
249 assert!(agent_has_tool(&def, "Write"));
250 assert!(!agent_has_tool(&def, "Bash"));
251 }
252
253 #[test]
254 fn test_agent_has_tool_deny_list() {
255 let def = make_def("a", ToolPolicy::DenyList(vec!["Bash".to_string()]));
256 assert!(agent_has_tool(&def, "Read"));
257 assert!(agent_has_tool(&def, "Write"));
258 assert!(!agent_has_tool(&def, "Bash"));
259 }
260
261 #[test]
262 fn test_agent_has_tool_inherit_all() {
263 let def = make_def("a", ToolPolicy::InheritAll);
264 assert!(agent_has_tool(&def, "Read"));
265 assert!(agent_has_tool(&def, "Bash"));
266 assert!(agent_has_tool(&def, "AnythingGoes"));
267 }
268
269 #[test]
270 fn test_agent_has_tool_disallowed_wins_over_allow_list() {
271 let def = make_def_with_disallowed(
273 "a",
274 ToolPolicy::AllowList(vec!["Read".to_string(), "Bash".to_string()]),
275 vec!["Bash".to_string()],
276 );
277 assert!(agent_has_tool(&def, "Read"));
278 assert!(!agent_has_tool(&def, "Bash"), "disallowed_tools must win");
279 }
280
281 #[test]
282 fn test_agent_has_tool_disallowed_wins_over_inherit_all() {
283 let def = make_def_with_disallowed(
284 "a",
285 ToolPolicy::InheritAll,
286 vec!["DangerousTool".to_string()],
287 );
288 assert!(agent_has_tool(&def, "Read"));
289 assert!(!agent_has_tool(&def, "DangerousTool"));
290 }
291}