Skip to main content

rustant_core/multi/
routing.rs

1//! Agent routing — directs tasks and channel messages to the appropriate agent.
2//!
3//! The `AgentRouter` evaluates routing rules in priority order and returns
4//! the ID of the agent that should handle a given task or message.
5
6use crate::channels::ChannelType;
7use uuid::Uuid;
8
9/// A routing rule that maps conditions to a target agent.
10#[derive(Debug, Clone)]
11pub struct AgentRoute {
12    /// Priority (lower = higher priority).
13    pub priority: u32,
14    /// The agent to route to if all conditions match.
15    pub target_agent_id: Uuid,
16    /// All conditions must match for this route to apply.
17    pub conditions: Vec<RouteCondition>,
18}
19
20/// Conditions that can be evaluated for routing decisions.
21#[derive(Debug, Clone)]
22pub enum RouteCondition {
23    /// Match on the channel type.
24    ChannelType(ChannelType),
25    /// Match on the user ID (platform-specific).
26    UserId(String),
27    /// Match if the message text contains a substring.
28    MessageContains(String),
29    /// Match if the task name/command starts with a prefix.
30    TaskPrefix(String),
31    /// Match a specific capability name.
32    CapabilityName(String),
33}
34
35/// A routing request containing the information needed to pick an agent.
36#[derive(Debug, Clone, Default)]
37pub struct RouteRequest {
38    pub channel_type: Option<ChannelType>,
39    pub user_id: Option<String>,
40    pub message_text: Option<String>,
41    pub task_name: Option<String>,
42    pub capability: Option<String>,
43}
44
45impl RouteRequest {
46    pub fn new() -> Self {
47        Self::default()
48    }
49
50    pub fn with_channel(mut self, ct: ChannelType) -> Self {
51        self.channel_type = Some(ct);
52        self
53    }
54
55    pub fn with_user(mut self, user_id: impl Into<String>) -> Self {
56        self.user_id = Some(user_id.into());
57        self
58    }
59
60    pub fn with_message(mut self, text: impl Into<String>) -> Self {
61        self.message_text = Some(text.into());
62        self
63    }
64
65    pub fn with_task(mut self, name: impl Into<String>) -> Self {
66        self.task_name = Some(name.into());
67        self
68    }
69
70    pub fn with_capability(mut self, cap: impl Into<String>) -> Self {
71        self.capability = Some(cap.into());
72        self
73    }
74}
75
76/// Routes tasks and messages to agents based on rules.
77pub struct AgentRouter {
78    routes: Vec<AgentRoute>,
79    /// Default agent for unmatched requests.
80    default_agent_id: Option<Uuid>,
81}
82
83impl AgentRouter {
84    pub fn new() -> Self {
85        Self {
86            routes: Vec::new(),
87            default_agent_id: None,
88        }
89    }
90
91    /// Set the default agent that handles unmatched requests.
92    pub fn with_default(mut self, agent_id: Uuid) -> Self {
93        self.default_agent_id = Some(agent_id);
94        self
95    }
96
97    /// Add a routing rule.
98    pub fn add_route(&mut self, route: AgentRoute) {
99        self.routes.push(route);
100        self.routes.sort_by_key(|r| r.priority);
101    }
102
103    /// Find the best-matching agent for a given request.
104    pub fn route(&self, request: &RouteRequest) -> Option<Uuid> {
105        for route in &self.routes {
106            if self.matches_all(&route.conditions, request) {
107                return Some(route.target_agent_id);
108            }
109        }
110        self.default_agent_id
111    }
112
113    /// Number of registered routes.
114    pub fn route_count(&self) -> usize {
115        self.routes.len()
116    }
117
118    fn matches_all(&self, conditions: &[RouteCondition], request: &RouteRequest) -> bool {
119        conditions.iter().all(|c| self.matches(c, request))
120    }
121
122    fn matches(&self, condition: &RouteCondition, request: &RouteRequest) -> bool {
123        match condition {
124            RouteCondition::ChannelType(ct) => request.channel_type.as_ref() == Some(ct),
125            RouteCondition::UserId(uid) => request.user_id.as_deref() == Some(uid.as_str()),
126            RouteCondition::MessageContains(sub) => request
127                .message_text
128                .as_ref()
129                .is_some_and(|t| t.contains(sub.as_str())),
130            RouteCondition::TaskPrefix(prefix) => request
131                .task_name
132                .as_ref()
133                .is_some_and(|t| t.starts_with(prefix.as_str())),
134            RouteCondition::CapabilityName(cap) => {
135                request.capability.as_deref() == Some(cap.as_str())
136            }
137        }
138    }
139}
140
141impl Default for AgentRouter {
142    fn default() -> Self {
143        Self::new()
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn test_router_no_routes_returns_default() {
153        let default_id = Uuid::new_v4();
154        let router = AgentRouter::new().with_default(default_id);
155        let req = RouteRequest::new().with_message("hello");
156        assert_eq!(router.route(&req), Some(default_id));
157    }
158
159    #[test]
160    fn test_router_no_routes_no_default_returns_none() {
161        let router = AgentRouter::new();
162        let req = RouteRequest::new().with_message("hello");
163        assert_eq!(router.route(&req), None);
164    }
165
166    #[test]
167    fn test_router_matches_channel_type() {
168        let agent_id = Uuid::new_v4();
169        let mut router = AgentRouter::new();
170        router.add_route(AgentRoute {
171            priority: 1,
172            target_agent_id: agent_id,
173            conditions: vec![RouteCondition::ChannelType(ChannelType::Telegram)],
174        });
175
176        let req = RouteRequest::new().with_channel(ChannelType::Telegram);
177        assert_eq!(router.route(&req), Some(agent_id));
178
179        let req2 = RouteRequest::new().with_channel(ChannelType::Discord);
180        assert_eq!(router.route(&req2), None);
181    }
182
183    #[test]
184    fn test_router_priority_ordering() {
185        let low_prio_agent = Uuid::new_v4();
186        let high_prio_agent = Uuid::new_v4();
187        let mut router = AgentRouter::new();
188
189        // Add low-priority first
190        router.add_route(AgentRoute {
191            priority: 10,
192            target_agent_id: low_prio_agent,
193            conditions: vec![RouteCondition::MessageContains("help".into())],
194        });
195        // Add high-priority second
196        router.add_route(AgentRoute {
197            priority: 1,
198            target_agent_id: high_prio_agent,
199            conditions: vec![RouteCondition::MessageContains("help".into())],
200        });
201
202        let req = RouteRequest::new().with_message("I need help");
203        // Should match higher priority (lower number) first
204        assert_eq!(router.route(&req), Some(high_prio_agent));
205    }
206
207    #[test]
208    fn test_router_multiple_conditions_all_must_match() {
209        let agent_id = Uuid::new_v4();
210        let mut router = AgentRouter::new();
211        router.add_route(AgentRoute {
212            priority: 1,
213            target_agent_id: agent_id,
214            conditions: vec![
215                RouteCondition::ChannelType(ChannelType::Discord),
216                RouteCondition::UserId("user-42".into()),
217            ],
218        });
219
220        // Both conditions met
221        let req = RouteRequest::new()
222            .with_channel(ChannelType::Discord)
223            .with_user("user-42");
224        assert_eq!(router.route(&req), Some(agent_id));
225
226        // Only one condition met
227        let req2 = RouteRequest::new()
228            .with_channel(ChannelType::Discord)
229            .with_user("user-99");
230        assert_eq!(router.route(&req2), None);
231    }
232}