use tracing::warn;
use clawft_types::agent_routing::{AgentRoute, AgentRoutingConfig};
use clawft_types::event::InboundMessage;
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RoutingResult {
Agent(String),
CatchAll(String),
NoMatch,
}
pub struct AgentRouter {
routes: Vec<AgentRoute>,
catch_all: Option<String>,
}
impl AgentRouter {
pub fn new(config: AgentRoutingConfig) -> Self {
Self {
routes: config.routes,
catch_all: config.catch_all,
}
}
pub fn empty() -> Self {
Self {
routes: Vec::new(),
catch_all: None,
}
}
pub fn with_catch_all(agent: impl Into<String>) -> Self {
Self {
routes: Vec::new(),
catch_all: Some(agent.into()),
}
}
pub fn route(&self, msg: &InboundMessage) -> RoutingResult {
if msg.sender_id.is_empty() {
return self.route_anonymous(msg);
}
for rule in &self.routes {
if rule.channel == msg.channel
&& rule.match_criteria.matches(&msg.sender_id, &msg.chat_id)
{
return RoutingResult::Agent(rule.agent.clone());
}
}
if let Some(ref catch_all) = self.catch_all {
RoutingResult::CatchAll(catch_all.clone())
} else {
warn!(
channel = %msg.channel,
sender_id = %msg.sender_id,
chat_id = %msg.chat_id,
"no agent configured for this channel/user"
);
RoutingResult::NoMatch
}
}
fn route_anonymous(&self, msg: &InboundMessage) -> RoutingResult {
if let Some(ref catch_all) = self.catch_all {
RoutingResult::CatchAll(catch_all.clone())
} else {
warn!(
channel = %msg.channel,
chat_id = %msg.chat_id,
"anonymous message with no catch-all configured"
);
RoutingResult::NoMatch
}
}
pub fn route_count(&self) -> usize {
self.routes.len()
}
pub fn has_catch_all(&self) -> bool {
self.catch_all.is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use clawft_types::agent_routing::{AgentRoute, AgentRoutingConfig, MatchCriteria};
use std::collections::HashMap;
fn make_msg(channel: &str, sender_id: &str, chat_id: &str) -> InboundMessage {
InboundMessage {
channel: channel.into(),
sender_id: sender_id.into(),
chat_id: chat_id.into(),
content: "test".into(),
timestamp: Utc::now(),
media: vec![],
metadata: HashMap::new(),
}
}
fn make_router() -> AgentRouter {
AgentRouter::new(AgentRoutingConfig {
routes: vec![
AgentRoute {
channel: "telegram".into(),
match_criteria: MatchCriteria {
user_id: Some("user123".into()),
..Default::default()
},
agent: "work-agent".into(),
},
AgentRoute {
channel: "whatsapp".into(),
match_criteria: MatchCriteria {
phone: Some("+15551234".into()),
..Default::default()
},
agent: "personal-agent".into(),
},
AgentRoute {
channel: "slack".into(),
match_criteria: MatchCriteria::default(),
agent: "slack-agent".into(),
},
],
catch_all: Some("default-agent".into()),
})
}
#[test]
fn route_first_match_wins() {
let router = make_router();
let msg = make_msg("telegram", "user123", "chat1");
assert_eq!(
router.route(&msg),
RoutingResult::Agent("work-agent".into())
);
}
#[test]
fn route_whatsapp_by_phone() {
let router = make_router();
let msg = make_msg("whatsapp", "+15551234", "chat2");
assert_eq!(
router.route(&msg),
RoutingResult::Agent("personal-agent".into())
);
}
#[test]
fn route_wildcard_match() {
let router = make_router();
let msg = make_msg("slack", "any_user", "any_chat");
assert_eq!(
router.route(&msg),
RoutingResult::Agent("slack-agent".into())
);
}
#[test]
fn route_no_match_falls_to_catch_all() {
let router = make_router();
let msg = make_msg("discord", "user456", "chat3");
assert_eq!(
router.route(&msg),
RoutingResult::CatchAll("default-agent".into())
);
}
#[test]
fn route_no_match_no_catch_all() {
let router = AgentRouter::new(AgentRoutingConfig {
routes: vec![AgentRoute {
channel: "telegram".into(),
match_criteria: MatchCriteria {
user_id: Some("user123".into()),
..Default::default()
},
agent: "work-agent".into(),
}],
catch_all: None,
});
let msg = make_msg("discord", "user456", "chat3");
assert_eq!(router.route(&msg), RoutingResult::NoMatch);
}
#[test]
fn route_anonymous_to_catch_all() {
let router = make_router();
let msg = make_msg("telegram", "", "chat1");
assert_eq!(
router.route(&msg),
RoutingResult::CatchAll("default-agent".into())
);
}
#[test]
fn route_anonymous_no_catch_all() {
let router = AgentRouter::empty();
let msg = make_msg("telegram", "", "chat1");
assert_eq!(router.route(&msg), RoutingResult::NoMatch);
}
#[test]
fn route_wrong_channel_no_match() {
let router = make_router();
let msg = make_msg("discord", "user123", "chat1");
assert_eq!(
router.route(&msg),
RoutingResult::CatchAll("default-agent".into())
);
}
#[test]
fn empty_router() {
let router = AgentRouter::empty();
assert_eq!(router.route_count(), 0);
assert!(!router.has_catch_all());
}
#[test]
fn with_catch_all_only() {
let router = AgentRouter::with_catch_all("fallback");
let msg = make_msg("any", "user", "chat");
assert_eq!(
router.route(&msg),
RoutingResult::CatchAll("fallback".into())
);
}
}