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 })
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}