Skip to main content

zeph_orchestration/
router.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Agent routing: selects the best agent definition for a given task.
5
6use zeph_subagent::{SubAgentDef, ToolPolicy};
7
8use super::graph::TaskNode;
9
10/// Selects the best agent definition for a given task.
11pub trait AgentRouter: Send + Sync {
12    /// Choose an agent for the task.
13    ///
14    /// Returns the agent definition name, or `None` if no suitable agent was found.
15    fn route(&self, task: &TaskNode, available: &[SubAgentDef]) -> Option<String>;
16}
17
18/// Rule-based agent router with a 3-step fallback chain:
19///
20/// 1. `task.agent_hint` exact match against available agent names.
21/// 2. Tool requirement matching: keywords in task description matched against agent
22///    tool policies (last-resort heuristic — see limitations note).
23/// 3. First available agent (fallback).
24///
25/// # Limitations
26///
27/// The keyword-to-tool matching (step 2) is intentionally basic. Common English words
28/// ("read", "build", "review", "edit") frequently appear in task descriptions unrelated
29/// to specific tool requirements. For reliable routing, the planner should always set
30/// `task.agent_hint` explicitly. Step 2 is a fallback for when no hint is provided and
31/// no exact match is found — treat it as a best-effort heuristic, not authoritative routing.
32/// The ultimate fallback (step 3) returns the first available agent unconditionally.
33///
34/// Step 2 only matches English keywords. Non-English task descriptions will always fall
35/// through to step 3.
36pub struct RuleBasedRouter;
37
38/// Keyword-to-tool mapping for step 2 routing.
39///
40/// Maps a lowercase substring of the task description to a tool name.
41/// Only matched when `agent_hint` is absent or not found (step 2 is last resort).
42const 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        // Step 1: exact match on agent_hint.
65        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        // Step 2: tool requirement matching by keyword scoring.
77        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        // Step 3: first available agent (unconditional fallback).
98        Some(available[0].name.clone())
99    }
100}
101
102/// Check if an agent definition allows a specific tool.
103fn agent_has_tool(def: &SubAgentDef, tool_name: &str) -> bool {
104    // Explicit deny-list wins over everything.
105    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    // --- AgentRouter tests ---
180
181    #[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        // hint not found → falls back to first available
199        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        // "implement" and "write code" match Write tool → writer-agent
219        assert_eq!(result.as_deref(), Some("writer-agent"));
220    }
221
222    #[test]
223    fn test_route_fallback_first_available() {
224        // No hint, no keyword matches → first available.
225        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    // --- agent_has_tool tests ---
244
245    #[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        // disallowed_tools takes priority even when tool is in AllowList.
275        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}