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