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            })
165        }
166        async fn generate_with_tools(&self, _: &str, _: &[ToolDefinition]) -> Result<LLMResponse> {
167            Ok(LLMResponse {
168                content: self.label.clone(),
169                tool_calls: vec![],
170                finish_reason: "stop".to_string(),
171                usage: None,
172            })
173        }
174        async fn generate_with_tools_and_history(
175            &self,
176            _: &[ares_llm::coordinator::ConversationMessage],
177            _: &[ToolDefinition],
178        ) -> Result<LLMResponse> {
179            Ok(LLMResponse {
180                content: self.label.clone(),
181                tool_calls: vec![],
182                finish_reason: "stop".to_string(),
183                usage: None,
184            })
185        }
186        async fn stream(&self, _: &str) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
187            Ok(Box::new(futures::stream::empty()))
188        }
189        async fn stream_with_system(&self, _: &str, _: &str) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
190            Ok(Box::new(futures::stream::empty()))
191        }
192        async fn stream_with_history(&self, _: &[(String, String)]) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
193            Ok(Box::new(futures::stream::empty()))
194        }
195    }
196
197    fn test_context() -> AgentContext {
198        AgentContext {
199            user_id: "test-user".to_string(),
200            session_id: "test-session".to_string(),
201            conversation_history: vec![],
202            user_memory: None,
203        }
204    }
205
206    #[test]
207    fn test_parse_routing_decision_exact_match() {
208        assert_eq!(RouterAgent::parse_routing_decision("product"), Some("product".to_string()));
209        assert_eq!(RouterAgent::parse_routing_decision("  SALES  "), Some("sales".to_string()));
210    }
211
212    #[test]
213    fn test_parse_routing_decision_embedded_and_suffix() {
214        assert_eq!(
215            RouterAgent::parse_routing_decision("I would route this to product"),
216            Some("product".to_string())
217        );
218        assert_eq!(RouterAgent::parse_routing_decision("product agent"), Some("product".to_string()));
219        assert_eq!(RouterAgent::parse_routing_decision("Route: finance."), Some("finance".to_string()));
220    }
221
222    #[test]
223    fn test_parse_routing_decision_unknown() {
224        assert_eq!(RouterAgent::parse_routing_decision("unknown-bot"), None);
225        assert_eq!(RouterAgent::parse_routing_decision(""), None);
226    }
227
228    #[test]
229    fn test_parse_routing_decision_substring_containment() {
230        assert_eq!(
231            RouterAgent::parse_routing_decision("our-products-catalog"),
232            Some("product".to_string())
233        );
234        assert_eq!(
235            RouterAgent::parse_routing_decision("enterprise-salesforce-data"),
236            Some("sales".to_string())
237        );
238    }
239
240    #[tokio::test]
241    async fn test_route_maps_specialized_agents() {
242        let ctx = test_context();
243        let cases = [
244            ("product", AgentType::Product),
245            ("invoice", AgentType::Invoice),
246            ("sales", AgentType::Sales),
247            ("finance", AgentType::Finance),
248            ("hr", AgentType::HR),
249        ];
250        for (label, expected) in cases {
251            let router = RouterAgent::new(Box::new(RoutingLlm::new(label)));
252            assert_eq!(router.route("query", &ctx).await.expect("route"), expected);
253        }
254    }
255
256    #[tokio::test]
257    async fn test_route_research_and_orchestrator_labels() {
258        let ctx = test_context();
259        for label in ["orchestrator", "research"] {
260            let router = RouterAgent::new(Box::new(RoutingLlm::new(label)));
261            assert_eq!(router.route("complex query", &ctx).await.expect("route"), AgentType::Orchestrator);
262        }
263    }
264
265    #[tokio::test]
266    async fn test_route_defaults_to_orchestrator_on_unparseable_output() {
267        let router = RouterAgent::new(Box::new(RoutingLlm::new("definitely-not-an-agent")));
268        assert_eq!(
269            router.route("anything", &test_context()).await.expect("route"),
270            AgentType::Orchestrator
271        );
272    }
273
274    #[test]
275    fn test_system_prompt_lists_available_agents() {
276        let prompt = RouterAgent::new(Box::new(RoutingLlm::new("product"))).system_prompt();
277        for agent in VALID_AGENTS {
278            if *agent == "research" {
279                continue;
280            }
281            assert!(prompt.contains(agent), "expected routing prompt to mention {agent}");
282        }
283        assert!(prompt.contains("orchestrator"));
284    }
285
286    #[test]
287    fn test_agent_type_is_router() {
288        assert_eq!(RouterAgent::new(Box::new(RoutingLlm::new("product"))).agent_type(), AgentType::Router);
289    }
290
291    #[tokio::test]
292    async fn test_execute_placeholder_response() {
293        let router = RouterAgent::new(Box::new(RoutingLlm::new("product")));
294        let resp = router.execute("ignored", &test_context()).await.expect("execute");
295        assert_eq!(resp.content, "router");
296    }
297}