Skip to main content

clawft_core/
agent_routing.rs

1//! Agent routing engine.
2//!
3//! Routes inbound messages to the appropriate agent based on channel
4//! and user match criteria. Supports first-match-wins semantics,
5//! catch-all fallback, and automatic workspace creation.
6//!
7//! # Routing algorithm
8//!
9//! 1. Walk routes in order; first match wins.
10//! 2. If no route matches and a catch-all is configured, use it.
11//! 3. If no route matches and no catch-all: reject with error.
12//! 4. Anonymous messages (empty `sender_id`) route to catch-all or
13//!    a dedicated "anonymous" agent with reduced permissions.
14
15use tracing::warn;
16
17use clawft_types::agent_routing::{AgentRoute, AgentRoutingConfig};
18use clawft_types::event::InboundMessage;
19
20/// Result of routing an inbound message to an agent.
21#[non_exhaustive]
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum RoutingResult {
24    /// Message routed to a specific agent by rule match.
25    Agent(String),
26    /// Message routed to the catch-all agent.
27    CatchAll(String),
28    /// No matching route and no catch-all configured.
29    NoMatch,
30}
31
32/// Engine that routes inbound messages to agent instances.
33///
34/// Created from an [`AgentRoutingConfig`] loaded from `clawft.toml`.
35/// Routes are evaluated in order; first match wins.
36pub struct AgentRouter {
37    routes: Vec<AgentRoute>,
38    catch_all: Option<String>,
39}
40
41impl AgentRouter {
42    /// Create a new router from configuration.
43    pub fn new(config: AgentRoutingConfig) -> Self {
44        Self {
45            routes: config.routes,
46            catch_all: config.catch_all,
47        }
48    }
49
50    /// Create an empty router with no rules.
51    pub fn empty() -> Self {
52        Self {
53            routes: Vec::new(),
54            catch_all: None,
55        }
56    }
57
58    /// Create a router with only a catch-all agent (no rules).
59    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    /// Route a message to the appropriate agent ID.
67    ///
68    /// # Routing semantics
69    ///
70    /// - First matching rule wins.
71    /// - Anonymous messages (empty `sender_id`) are routed to the
72    ///   catch-all agent (if configured).
73    /// - No match + no catch-all: returns [`RoutingResult::NoMatch`]
74    ///   and logs a warning.
75    pub fn route(&self, msg: &InboundMessage) -> RoutingResult {
76        // Anonymous messages go straight to catch-all.
77        if msg.sender_id.is_empty() {
78            return self.route_anonymous(msg);
79        }
80
81        // Walk rules in order; first match wins.
82        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        // No rule matched.
91        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    /// Route an anonymous message (empty sender_id).
105    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    /// Number of configured routes.
119    pub fn route_count(&self) -> usize {
120        self.routes.len()
121    }
122
123    /// Whether a catch-all agent is configured.
124    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        // Slack rule has empty match criteria (wildcard).
201        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        // user123 exists but on telegram, not discord.
256        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}