Skip to main content

zeph_core/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 crate::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::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    // --- AgentRouter tests ---
179
180    #[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        // hint not found → falls back to first available
198        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        // "implement" and "write code" match Write tool → writer-agent
218        assert_eq!(result.as_deref(), Some("writer-agent"));
219    }
220
221    #[test]
222    fn test_route_fallback_first_available() {
223        // No hint, no keyword matches → first available.
224        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    // --- agent_has_tool tests ---
243
244    #[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        // disallowed_tools takes priority even when tool is in AllowList.
274        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}