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    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    // --- AgentRouter tests ---
177
178    #[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        // hint not found → falls back to first available
196        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        // "implement" and "write code" match Write tool → writer-agent
216        assert_eq!(result.as_deref(), Some("writer-agent"));
217    }
218
219    #[test]
220    fn test_route_fallback_first_available() {
221        // No hint, no keyword matches → first available.
222        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    // --- agent_has_tool tests ---
241
242    #[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        // disallowed_tools takes priority even when tool is in AllowList.
272        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}