1use crate::{Agent, AgentResponse};
2use ares_llm::LLMClient;
3use ares_types::types::{AgentContext, AgentType, Result};
4use async_trait::async_trait;
5
6const VALID_AGENTS: &[&str] = &[
8 "product",
9 "invoice",
10 "sales",
11 "finance",
12 "hr",
13 "orchestrator",
14 "research",
15];
16
17pub struct RouterAgent {
22 llm: Box<dyn LLMClient>,
23}
24
25impl RouterAgent {
26 pub fn new(llm: Box<dyn LLMClient>) -> Self {
28 Self { llm }
29 }
30
31 fn parse_routing_decision(output: &str) -> Option<String> {
39 let trimmed = output.trim().to_lowercase();
40
41 if VALID_AGENTS.contains(&trimmed.as_str()) {
43 return Some(trimmed);
44 }
45
46 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 for agent in VALID_AGENTS {
57 if trimmed.contains(agent) {
58 return Some(agent.to_string());
59 }
60 }
61
62 None
63 }
64
65 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 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 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 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}