Skip to main content

clawft_types/
agent_routing.rs

1//! Agent routing configuration types.
2//!
3//! Defines the config schema for the L1 agent routing table which maps
4//! channel + user combinations to specific agent instances.
5//!
6//! # Configuration format
7//!
8//! ```toml
9//! [[agent_routes]]
10//! channel = "telegram"
11//! match = { user_id = "12345" }
12//! agent = "work-agent"
13//!
14//! [[agent_routes]]
15//! channel = "whatsapp"
16//! match = { phone = "+1..." }
17//! agent = "personal-agent"
18//!
19//! [agent_routing]
20//! catch_all = "default-agent"
21//! ```
22
23use serde::{Deserialize, Serialize};
24
25/// A single routing rule that maps channel + match criteria to an agent.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct AgentRoute {
28    /// Channel name to match (e.g. "telegram", "slack", "discord").
29    pub channel: String,
30
31    /// Criteria to match within the channel.
32    #[serde(rename = "match", default)]
33    pub match_criteria: MatchCriteria,
34
35    /// Agent ID to route to when this rule matches.
36    pub agent: String,
37}
38
39/// Criteria used to match an inbound message to a routing rule.
40///
41/// All fields are optional. A field that is `None` matches any value.
42/// Multiple non-`None` fields are AND-ed: all must match.
43#[derive(Debug, Clone, Default, Serialize, Deserialize)]
44pub struct MatchCriteria {
45    /// Match by sender user ID.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub user_id: Option<String>,
48
49    /// Match by phone number (for channels like WhatsApp).
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub phone: Option<String>,
52
53    /// Match by chat/conversation ID.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub chat_id: Option<String>,
56}
57
58impl MatchCriteria {
59    /// Returns true if all specified criteria match the given values.
60    ///
61    /// Fields that are `None` are treated as wildcards (always match).
62    pub fn matches(&self, sender_id: &str, chat_id: &str) -> bool {
63        if let Some(ref uid) = self.user_id
64            && uid != sender_id
65        {
66            return false;
67        }
68        if let Some(ref phone) = self.phone
69            && phone != sender_id
70            && phone != chat_id
71        {
72            // Phone can match either sender_id or chat_id.
73            return false;
74        }
75        if let Some(ref cid) = self.chat_id
76            && cid != chat_id
77        {
78            return false;
79        }
80        true
81    }
82
83    /// Returns true if no criteria are specified (matches everything).
84    pub fn is_empty(&self) -> bool {
85        self.user_id.is_none() && self.phone.is_none() && self.chat_id.is_none()
86    }
87}
88
89/// Top-level agent routing configuration.
90#[derive(Debug, Clone, Default, Serialize, Deserialize)]
91pub struct AgentRoutingConfig {
92    /// Ordered routing rules. First match wins.
93    #[serde(default)]
94    pub routes: Vec<AgentRoute>,
95
96    /// Optional catch-all agent for messages that match no rule.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub catch_all: Option<String>,
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn match_criteria_empty_matches_all() {
107        let criteria = MatchCriteria::default();
108        assert!(criteria.is_empty());
109        assert!(criteria.matches("any_user", "any_chat"));
110    }
111
112    #[test]
113    fn match_criteria_user_id() {
114        let criteria = MatchCriteria {
115            user_id: Some("user123".into()),
116            ..Default::default()
117        };
118        assert!(criteria.matches("user123", "chat1"));
119        assert!(!criteria.matches("other_user", "chat1"));
120    }
121
122    #[test]
123    fn match_criteria_phone() {
124        let criteria = MatchCriteria {
125            phone: Some("+1234567890".into()),
126            ..Default::default()
127        };
128        // Phone matches sender_id.
129        assert!(criteria.matches("+1234567890", "chat1"));
130        // Phone matches chat_id.
131        assert!(criteria.matches("other", "+1234567890"));
132        // No match.
133        assert!(!criteria.matches("other", "chat1"));
134    }
135
136    #[test]
137    fn match_criteria_chat_id() {
138        let criteria = MatchCriteria {
139            chat_id: Some("chat42".into()),
140            ..Default::default()
141        };
142        assert!(criteria.matches("any_user", "chat42"));
143        assert!(!criteria.matches("any_user", "other_chat"));
144    }
145
146    #[test]
147    fn match_criteria_combined_and() {
148        let criteria = MatchCriteria {
149            user_id: Some("user1".into()),
150            chat_id: Some("chat1".into()),
151            ..Default::default()
152        };
153        // Both must match.
154        assert!(criteria.matches("user1", "chat1"));
155        // Wrong user.
156        assert!(!criteria.matches("user2", "chat1"));
157        // Wrong chat.
158        assert!(!criteria.matches("user1", "chat2"));
159    }
160
161    #[test]
162    fn agent_route_serde_roundtrip() {
163        let route = AgentRoute {
164            channel: "telegram".into(),
165            match_criteria: MatchCriteria {
166                user_id: Some("12345".into()),
167                ..Default::default()
168            },
169            agent: "work-agent".into(),
170        };
171        let json = serde_json::to_string(&route).unwrap();
172        let restored: AgentRoute = serde_json::from_str(&json).unwrap();
173        assert_eq!(restored.channel, "telegram");
174        assert_eq!(restored.agent, "work-agent");
175        assert_eq!(restored.match_criteria.user_id.as_deref(), Some("12345"));
176    }
177
178    #[test]
179    fn agent_routing_config_defaults() {
180        let cfg = AgentRoutingConfig::default();
181        assert!(cfg.routes.is_empty());
182        assert!(cfg.catch_all.is_none());
183    }
184
185    #[test]
186    fn agent_routing_config_serde_with_catch_all() {
187        let json = r#"{
188            "routes": [
189                {"channel": "telegram", "match": {"user_id": "123"}, "agent": "bot-a"},
190                {"channel": "slack", "agent": "bot-b"}
191            ],
192            "catch_all": "default-bot"
193        }"#;
194        let cfg: AgentRoutingConfig = serde_json::from_str(json).unwrap();
195        assert_eq!(cfg.routes.len(), 2);
196        assert_eq!(cfg.routes[0].agent, "bot-a");
197        assert_eq!(cfg.routes[1].agent, "bot-b");
198        assert_eq!(cfg.catch_all.as_deref(), Some("default-bot"));
199    }
200}