use crate::{Agent, AgentResponse};
use ares_llm::LLMClient;
use ares_types::types::{AgentContext, AgentType, Result};
use async_trait::async_trait;
const VALID_AGENTS: &[&str] = &[
"product",
"invoice",
"sales",
"finance",
"hr",
"orchestrator",
"research",
];
pub struct RouterAgent {
llm: Box<dyn LLMClient>,
}
impl RouterAgent {
pub fn new(llm: Box<dyn LLMClient>) -> Self {
Self { llm }
}
fn parse_routing_decision(output: &str) -> Option<String> {
let trimmed = output.trim().to_lowercase();
if VALID_AGENTS.contains(&trimmed.as_str()) {
return Some(trimmed);
}
for word in trimmed.split(|c: char| c.is_whitespace() || c == ':' || c == ',' || c == '.') {
let word = word.trim();
if VALID_AGENTS.contains(&word) {
return Some(word.to_string());
}
}
for agent in VALID_AGENTS {
if trimmed.contains(agent) {
return Some(agent.to_string());
}
}
None
}
pub async fn route(&self, query: &str, _context: &AgentContext) -> Result<AgentType> {
let system_prompt = self.system_prompt();
let response = self.llm.generate_with_system(&system_prompt, query).await?;
let agent_name = Self::parse_routing_decision(&response);
match agent_name.as_deref() {
Some("product") => Ok(AgentType::Product),
Some("invoice") => Ok(AgentType::Invoice),
Some("sales") => Ok(AgentType::Sales),
Some("finance") => Ok(AgentType::Finance),
Some("hr") => Ok(AgentType::HR),
Some("orchestrator") | Some("research") => Ok(AgentType::Orchestrator),
_ => {
tracing::debug!(
"Router could not parse output '{}', defaulting to orchestrator",
response
);
Ok(AgentType::Orchestrator)
}
}
}
}
#[async_trait]
impl Agent for RouterAgent {
async fn execute(&self, _input: &str, _context: &AgentContext) -> Result<AgentResponse> {
Ok(AgentResponse {
content: "router".to_string(),
usage: None,
metadata: None,
})
}
fn system_prompt(&self) -> String {
r#"You are a routing agent that classifies user queries and routes them to the appropriate specialized agent.
Available agents:
- product: Product information, recommendations, catalog queries
- invoice: Invoice processing, billing questions, payment status
- sales: Sales data, analytics, performance metrics
- finance: Financial reports, budgets, expense analysis
- hr: Human resources, employee information, policies
- orchestrator: Complex queries requiring multiple agents or research
Analyze the user's query and respond with ONLY the agent name (lowercase, one word).
Examples:
- "What products do we have?" → product
- "Show me last quarter's sales" → sales
- "What's our hiring policy?" → hr
- "Create a comprehensive market analysis" → orchestrator
Respond with ONLY the agent name, nothing else."#.to_string()
}
fn agent_type(&self) -> AgentType {
AgentType::Router
}
}
#[cfg(test)]
mod tests {
use super::*;
use ares_llm::{LLMClient, LLMResponse};
use ares_types::types::ToolDefinition;
use async_trait::async_trait;
struct RoutingLlm {
label: String,
}
impl RoutingLlm {
fn new(label: impl Into<String>) -> Self {
Self { label: label.into() }
}
}
#[async_trait]
impl LLMClient for RoutingLlm {
fn model_name(&self) -> &str {
"routing-test"
}
async fn generate(&self, _: &str) -> Result<String> {
Ok(self.label.clone())
}
async fn generate_with_system(&self, _: &str, _: &str) -> Result<String> {
Ok(self.label.clone())
}
async fn generate_with_history(&self, _: &[(String, String)]) -> Result<LLMResponse> {
Ok(LLMResponse {
content: self.label.clone(),
tool_calls: vec![],
finish_reason: "stop".to_string(),
usage: None,
})
}
async fn generate_with_tools(&self, _: &str, _: &[ToolDefinition]) -> Result<LLMResponse> {
Ok(LLMResponse {
content: self.label.clone(),
tool_calls: vec![],
finish_reason: "stop".to_string(),
usage: None,
})
}
async fn generate_with_tools_and_history(
&self,
_: &[ares_llm::coordinator::ConversationMessage],
_: &[ToolDefinition],
) -> Result<LLMResponse> {
Ok(LLMResponse {
content: self.label.clone(),
tool_calls: vec![],
finish_reason: "stop".to_string(),
usage: None,
})
}
async fn stream(&self, _: &str) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
Ok(Box::new(futures::stream::empty()))
}
async fn stream_with_system(&self, _: &str, _: &str) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
Ok(Box::new(futures::stream::empty()))
}
async fn stream_with_history(&self, _: &[(String, String)]) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
Ok(Box::new(futures::stream::empty()))
}
}
fn test_context() -> AgentContext {
AgentContext {
user_id: "test-user".to_string(),
session_id: "test-session".to_string(),
conversation_history: vec![],
user_memory: None,
}
}
#[test]
fn test_parse_routing_decision_exact_match() {
assert_eq!(RouterAgent::parse_routing_decision("product"), Some("product".to_string()));
assert_eq!(RouterAgent::parse_routing_decision(" SALES "), Some("sales".to_string()));
}
#[test]
fn test_parse_routing_decision_embedded_and_suffix() {
assert_eq!(
RouterAgent::parse_routing_decision("I would route this to product"),
Some("product".to_string())
);
assert_eq!(RouterAgent::parse_routing_decision("product agent"), Some("product".to_string()));
assert_eq!(RouterAgent::parse_routing_decision("Route: finance."), Some("finance".to_string()));
}
#[test]
fn test_parse_routing_decision_unknown() {
assert_eq!(RouterAgent::parse_routing_decision("unknown-bot"), None);
assert_eq!(RouterAgent::parse_routing_decision(""), None);
}
#[test]
fn test_parse_routing_decision_substring_containment() {
assert_eq!(
RouterAgent::parse_routing_decision("our-products-catalog"),
Some("product".to_string())
);
assert_eq!(
RouterAgent::parse_routing_decision("enterprise-salesforce-data"),
Some("sales".to_string())
);
}
#[tokio::test]
async fn test_route_maps_specialized_agents() {
let ctx = test_context();
let cases = [
("product", AgentType::Product),
("invoice", AgentType::Invoice),
("sales", AgentType::Sales),
("finance", AgentType::Finance),
("hr", AgentType::HR),
];
for (label, expected) in cases {
let router = RouterAgent::new(Box::new(RoutingLlm::new(label)));
assert_eq!(router.route("query", &ctx).await.expect("route"), expected);
}
}
#[tokio::test]
async fn test_route_research_and_orchestrator_labels() {
let ctx = test_context();
for label in ["orchestrator", "research"] {
let router = RouterAgent::new(Box::new(RoutingLlm::new(label)));
assert_eq!(router.route("complex query", &ctx).await.expect("route"), AgentType::Orchestrator);
}
}
#[tokio::test]
async fn test_route_defaults_to_orchestrator_on_unparseable_output() {
let router = RouterAgent::new(Box::new(RoutingLlm::new("definitely-not-an-agent")));
assert_eq!(
router.route("anything", &test_context()).await.expect("route"),
AgentType::Orchestrator
);
}
#[test]
fn test_system_prompt_lists_available_agents() {
let prompt = RouterAgent::new(Box::new(RoutingLlm::new("product"))).system_prompt();
for agent in VALID_AGENTS {
if *agent == "research" {
continue;
}
assert!(prompt.contains(agent), "expected routing prompt to mention {agent}");
}
assert!(prompt.contains("orchestrator"));
}
#[test]
fn test_agent_type_is_router() {
assert_eq!(RouterAgent::new(Box::new(RoutingLlm::new("product"))).agent_type(), AgentType::Router);
}
#[tokio::test]
async fn test_execute_placeholder_response() {
let router = RouterAgent::new(Box::new(RoutingLlm::new("product")));
let resp = router.execute("ignored", &test_context()).await.expect("execute");
assert_eq!(resp.content, "router");
}
}