Skip to main content

ares_agent/
router.rs

1use crate::{Agent, AgentResponse};
2use ares_llm::LLMClient;
3use ares_types::types::{AgentContext, AgentType, Result};
4use async_trait::async_trait;
5
6/// Valid agent names for routing
7const VALID_AGENTS: &[&str] = &[
8    "product",
9    "invoice",
10    "sales",
11    "finance",
12    "hr",
13    "orchestrator",
14    "research",
15];
16
17/// Router agent that directs queries to specialized agents.
18///
19/// Uses an LLM to analyze user queries and determine which
20/// specialized agent is best suited to handle them.
21pub struct RouterAgent {
22    llm: Box<dyn LLMClient>,
23}
24
25impl RouterAgent {
26    /// Creates a new RouterAgent with the given LLM client.
27    pub fn new(llm: Box<dyn LLMClient>) -> Self {
28        Self { llm }
29    }
30
31    /// Parse routing decision from LLM output
32    ///
33    /// This handles various LLM output formats:
34    /// - Clean output: "product"
35    /// - With whitespace: "  product  "
36    /// - With extra text: "I would route this to product"
37    /// - Agent suffix: "product agent"
38    fn parse_routing_decision(output: &str) -> Option<String> {
39        let trimmed = output.trim().to_lowercase();
40
41        // First, try exact match
42        if VALID_AGENTS.contains(&trimmed.as_str()) {
43            return Some(trimmed);
44        }
45
46        // Try to extract valid agent name from output
47        // Split by common delimiters and check each word
48        for word in trimmed.split(|c: char| c.is_whitespace() || c == ':' || c == ',' || c == '.') {
49            let word = word.trim();
50            if VALID_AGENTS.contains(&word) {
51                return Some(word.to_string());
52            }
53        }
54
55        // Check if any valid agent name is contained in the output
56        for agent in VALID_AGENTS {
57            if trimmed.contains(agent) {
58                return Some(agent.to_string());
59            }
60        }
61
62        None
63    }
64
65    /// Routes a query to the appropriate agent type.
66    pub async fn route(&self, query: &str, _context: &AgentContext) -> Result<AgentType> {
67        let system_prompt = self.system_prompt();
68        let response = self.llm.generate_with_system(&system_prompt, query).await?;
69
70        // Parse the response with robust matching
71        let agent_name = Self::parse_routing_decision(&response);
72
73        match agent_name.as_deref() {
74            Some("product") => Ok(AgentType::Product),
75            Some("invoice") => Ok(AgentType::Invoice),
76            Some("sales") => Ok(AgentType::Sales),
77            Some("finance") => Ok(AgentType::Finance),
78            Some("hr") => Ok(AgentType::HR),
79            Some("orchestrator") | Some("research") => Ok(AgentType::Orchestrator),
80            _ => {
81                // Default to orchestrator for complex queries or unrecognized routing
82                tracing::debug!(
83                    "Router could not parse output '{}', defaulting to orchestrator",
84                    response
85                );
86                Ok(AgentType::Orchestrator)
87            }
88        }
89    }
90}
91
92#[async_trait]
93impl Agent for RouterAgent {
94    async fn execute(&self, _input: &str, _context: &AgentContext) -> Result<AgentResponse> {
95        // Note: RouterAgent.route() is called by the orchestrator/chat handler,
96        // not through the Agent trait execute() method. This is a placeholder.
97        Ok(AgentResponse {
98            content: "router".to_string(),
99            usage: None,
100            metadata: None,
101        })
102    }
103
104    fn system_prompt(&self) -> String {
105        r#"You are a routing agent that classifies user queries and routes them to the appropriate specialized agent.
106
107Available agents:
108- product: Product information, recommendations, catalog queries
109- invoice: Invoice processing, billing questions, payment status
110- sales: Sales data, analytics, performance metrics
111- finance: Financial reports, budgets, expense analysis
112- hr: Human resources, employee information, policies
113- orchestrator: Complex queries requiring multiple agents or research
114
115Analyze the user's query and respond with ONLY the agent name (lowercase, one word).
116Examples:
117- "What products do we have?" → product
118- "Show me last quarter's sales" → sales
119- "What's our hiring policy?" → hr
120- "Create a comprehensive market analysis" → orchestrator
121
122Respond with ONLY the agent name, nothing else."#.to_string()
123    }
124
125    fn agent_type(&self) -> AgentType {
126        AgentType::Router
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use ares_llm::{LLMClient, LLMResponse};
134    use ares_types::types::ToolDefinition;
135    use async_trait::async_trait;
136
137    struct RoutingLlm {
138        label: String,
139    }
140
141    impl RoutingLlm {
142        fn new(label: impl Into<String>) -> Self {
143            Self { label: label.into() }
144        }
145    }
146
147    #[async_trait]
148    impl LLMClient for RoutingLlm {
149        fn model_name(&self) -> &str {
150            "routing-test"
151        }
152        async fn generate(&self, _: &str) -> Result<String> {
153            Ok(self.label.clone())
154        }
155        async fn generate_with_system(&self, _: &str, _: &str) -> Result<String> {
156            Ok(self.label.clone())
157        }
158        async fn generate_with_history(&self, _: &[(String, String)]) -> Result<LLMResponse> {
159            Ok(LLMResponse {
160                content: self.label.clone(),
161                tool_calls: vec![],
162                finish_reason: "stop".to_string(),
163                usage: None,
164                reasoning_content: None,
165                response_id: None,
166            })
167        }
168        async fn generate_with_tools(&self, _: &str, _: &[ToolDefinition]) -> Result<LLMResponse> {
169            Ok(LLMResponse {
170                content: self.label.clone(),
171                tool_calls: vec![],
172                finish_reason: "stop".to_string(),
173                usage: None,
174                reasoning_content: None,
175                response_id: None,
176            })
177        }
178        async fn generate_with_tools_and_history(
179            &self,
180            _: &[ares_llm::coordinator::ConversationMessage],
181            _: &[ToolDefinition],
182        ) -> Result<LLMResponse> {
183            Ok(LLMResponse {
184                content: self.label.clone(),
185                tool_calls: vec![],
186                finish_reason: "stop".to_string(),
187                usage: None,
188                reasoning_content: None,
189                response_id: None,
190            })
191        }
192        async fn stream(&self, _: &str) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
193            Ok(Box::new(futures::stream::empty()))
194        }
195        async fn stream_with_system(&self, _: &str, _: &str) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
196            Ok(Box::new(futures::stream::empty()))
197        }
198        async fn stream_with_history(&self, _: &[(String, String)]) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
199            Ok(Box::new(futures::stream::empty()))
200        }
201    }
202
203    fn test_context() -> AgentContext {
204        AgentContext {
205            user_id: "test-user".to_string(),
206            session_id: "test-session".to_string(),
207            conversation_history: vec![],
208            user_memory: None,
209        }
210    }
211
212    #[test]
213    fn test_parse_routing_decision_exact_match() {
214        assert_eq!(RouterAgent::parse_routing_decision("product"), Some("product".to_string()));
215        assert_eq!(RouterAgent::parse_routing_decision("  SALES  "), Some("sales".to_string()));
216    }
217
218    #[test]
219    fn test_parse_routing_decision_embedded_and_suffix() {
220        assert_eq!(
221            RouterAgent::parse_routing_decision("I would route this to product"),
222            Some("product".to_string())
223        );
224        assert_eq!(RouterAgent::parse_routing_decision("product agent"), Some("product".to_string()));
225        assert_eq!(RouterAgent::parse_routing_decision("Route: finance."), Some("finance".to_string()));
226    }
227
228    #[test]
229    fn test_parse_routing_decision_unknown() {
230        assert_eq!(RouterAgent::parse_routing_decision("unknown-bot"), None);
231        assert_eq!(RouterAgent::parse_routing_decision(""), None);
232    }
233
234    #[test]
235    fn test_parse_routing_decision_substring_containment() {
236        assert_eq!(
237            RouterAgent::parse_routing_decision("our-products-catalog"),
238            Some("product".to_string())
239        );
240        assert_eq!(
241            RouterAgent::parse_routing_decision("enterprise-salesforce-data"),
242            Some("sales".to_string())
243        );
244    }
245
246    #[tokio::test]
247    async fn test_route_maps_specialized_agents() {
248        let ctx = test_context();
249        let cases = [
250            ("product", AgentType::Product),
251            ("invoice", AgentType::Invoice),
252            ("sales", AgentType::Sales),
253            ("finance", AgentType::Finance),
254            ("hr", AgentType::HR),
255        ];
256        for (label, expected) in cases {
257            let router = RouterAgent::new(Box::new(RoutingLlm::new(label)));
258            assert_eq!(router.route("query", &ctx).await.expect("route"), expected);
259        }
260    }
261
262    #[tokio::test]
263    async fn test_route_research_and_orchestrator_labels() {
264        let ctx = test_context();
265        for label in ["orchestrator", "research"] {
266            let router = RouterAgent::new(Box::new(RoutingLlm::new(label)));
267            assert_eq!(router.route("complex query", &ctx).await.expect("route"), AgentType::Orchestrator);
268        }
269    }
270
271    #[tokio::test]
272    async fn test_route_defaults_to_orchestrator_on_unparseable_output() {
273        let router = RouterAgent::new(Box::new(RoutingLlm::new("definitely-not-an-agent")));
274        assert_eq!(
275            router.route("anything", &test_context()).await.expect("route"),
276            AgentType::Orchestrator
277        );
278    }
279
280    #[test]
281    fn test_system_prompt_lists_available_agents() {
282        let prompt = RouterAgent::new(Box::new(RoutingLlm::new("product"))).system_prompt();
283        for agent in VALID_AGENTS {
284            if *agent == "research" {
285                continue;
286            }
287            assert!(prompt.contains(agent), "expected routing prompt to mention {agent}");
288        }
289        assert!(prompt.contains("orchestrator"));
290    }
291
292    #[test]
293    fn test_agent_type_is_router() {
294        assert_eq!(RouterAgent::new(Box::new(RoutingLlm::new("product"))).agent_type(), AgentType::Router);
295    }
296
297    #[tokio::test]
298    async fn test_execute_placeholder_response() {
299        let router = RouterAgent::new(Box::new(RoutingLlm::new("product")));
300        let resp = router.execute("ignored", &test_context()).await.expect("execute");
301        assert_eq!(resp.content, "router");
302    }
303}