clawft_core/
agent_routing.rs1use tracing::warn;
16
17use clawft_types::agent_routing::{AgentRoute, AgentRoutingConfig};
18use clawft_types::event::InboundMessage;
19
20#[non_exhaustive]
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum RoutingResult {
24 Agent(String),
26 CatchAll(String),
28 NoMatch,
30}
31
32pub struct AgentRouter {
37 routes: Vec<AgentRoute>,
38 catch_all: Option<String>,
39}
40
41impl AgentRouter {
42 pub fn new(config: AgentRoutingConfig) -> Self {
44 Self {
45 routes: config.routes,
46 catch_all: config.catch_all,
47 }
48 }
49
50 pub fn empty() -> Self {
52 Self {
53 routes: Vec::new(),
54 catch_all: None,
55 }
56 }
57
58 pub fn with_catch_all(agent: impl Into<String>) -> Self {
60 Self {
61 routes: Vec::new(),
62 catch_all: Some(agent.into()),
63 }
64 }
65
66 pub fn route(&self, msg: &InboundMessage) -> RoutingResult {
76 if msg.sender_id.is_empty() {
78 return self.route_anonymous(msg);
79 }
80
81 for rule in &self.routes {
83 if rule.channel == msg.channel
84 && rule.match_criteria.matches(&msg.sender_id, &msg.chat_id)
85 {
86 return RoutingResult::Agent(rule.agent.clone());
87 }
88 }
89
90 if let Some(ref catch_all) = self.catch_all {
92 RoutingResult::CatchAll(catch_all.clone())
93 } else {
94 warn!(
95 channel = %msg.channel,
96 sender_id = %msg.sender_id,
97 chat_id = %msg.chat_id,
98 "no agent configured for this channel/user"
99 );
100 RoutingResult::NoMatch
101 }
102 }
103
104 fn route_anonymous(&self, msg: &InboundMessage) -> RoutingResult {
106 if let Some(ref catch_all) = self.catch_all {
107 RoutingResult::CatchAll(catch_all.clone())
108 } else {
109 warn!(
110 channel = %msg.channel,
111 chat_id = %msg.chat_id,
112 "anonymous message with no catch-all configured"
113 );
114 RoutingResult::NoMatch
115 }
116 }
117
118 pub fn route_count(&self) -> usize {
120 self.routes.len()
121 }
122
123 pub fn has_catch_all(&self) -> bool {
125 self.catch_all.is_some()
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132 use chrono::Utc;
133 use clawft_types::agent_routing::{AgentRoute, AgentRoutingConfig, MatchCriteria};
134 use std::collections::HashMap;
135
136 fn make_msg(channel: &str, sender_id: &str, chat_id: &str) -> InboundMessage {
137 InboundMessage {
138 channel: channel.into(),
139 sender_id: sender_id.into(),
140 chat_id: chat_id.into(),
141 content: "test".into(),
142 timestamp: Utc::now(),
143 media: vec![],
144 metadata: HashMap::new(),
145 }
146 }
147
148 fn make_router() -> AgentRouter {
149 AgentRouter::new(AgentRoutingConfig {
150 routes: vec![
151 AgentRoute {
152 channel: "telegram".into(),
153 match_criteria: MatchCriteria {
154 user_id: Some("user123".into()),
155 ..Default::default()
156 },
157 agent: "work-agent".into(),
158 },
159 AgentRoute {
160 channel: "whatsapp".into(),
161 match_criteria: MatchCriteria {
162 phone: Some("+15551234".into()),
163 ..Default::default()
164 },
165 agent: "personal-agent".into(),
166 },
167 AgentRoute {
168 channel: "slack".into(),
169 match_criteria: MatchCriteria::default(),
170 agent: "slack-agent".into(),
171 },
172 ],
173 catch_all: Some("default-agent".into()),
174 })
175 }
176
177 #[test]
178 fn route_first_match_wins() {
179 let router = make_router();
180 let msg = make_msg("telegram", "user123", "chat1");
181 assert_eq!(
182 router.route(&msg),
183 RoutingResult::Agent("work-agent".into())
184 );
185 }
186
187 #[test]
188 fn route_whatsapp_by_phone() {
189 let router = make_router();
190 let msg = make_msg("whatsapp", "+15551234", "chat2");
191 assert_eq!(
192 router.route(&msg),
193 RoutingResult::Agent("personal-agent".into())
194 );
195 }
196
197 #[test]
198 fn route_wildcard_match() {
199 let router = make_router();
200 let msg = make_msg("slack", "any_user", "any_chat");
202 assert_eq!(
203 router.route(&msg),
204 RoutingResult::Agent("slack-agent".into())
205 );
206 }
207
208 #[test]
209 fn route_no_match_falls_to_catch_all() {
210 let router = make_router();
211 let msg = make_msg("discord", "user456", "chat3");
212 assert_eq!(
213 router.route(&msg),
214 RoutingResult::CatchAll("default-agent".into())
215 );
216 }
217
218 #[test]
219 fn route_no_match_no_catch_all() {
220 let router = AgentRouter::new(AgentRoutingConfig {
221 routes: vec![AgentRoute {
222 channel: "telegram".into(),
223 match_criteria: MatchCriteria {
224 user_id: Some("user123".into()),
225 ..Default::default()
226 },
227 agent: "work-agent".into(),
228 }],
229 catch_all: None,
230 });
231 let msg = make_msg("discord", "user456", "chat3");
232 assert_eq!(router.route(&msg), RoutingResult::NoMatch);
233 }
234
235 #[test]
236 fn route_anonymous_to_catch_all() {
237 let router = make_router();
238 let msg = make_msg("telegram", "", "chat1");
239 assert_eq!(
240 router.route(&msg),
241 RoutingResult::CatchAll("default-agent".into())
242 );
243 }
244
245 #[test]
246 fn route_anonymous_no_catch_all() {
247 let router = AgentRouter::empty();
248 let msg = make_msg("telegram", "", "chat1");
249 assert_eq!(router.route(&msg), RoutingResult::NoMatch);
250 }
251
252 #[test]
253 fn route_wrong_channel_no_match() {
254 let router = make_router();
255 let msg = make_msg("discord", "user123", "chat1");
257 assert_eq!(
258 router.route(&msg),
259 RoutingResult::CatchAll("default-agent".into())
260 );
261 }
262
263 #[test]
264 fn empty_router() {
265 let router = AgentRouter::empty();
266 assert_eq!(router.route_count(), 0);
267 assert!(!router.has_catch_all());
268 }
269
270 #[test]
271 fn with_catch_all_only() {
272 let router = AgentRouter::with_catch_all("fallback");
273 let msg = make_msg("any", "user", "chat");
274 assert_eq!(
275 router.route(&msg),
276 RoutingResult::CatchAll("fallback".into())
277 );
278 }
279}