1use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
8#[non_exhaustive]
9pub enum SignalKind {
10 Stop,
12 UserMessage,
14 ToolResult,
16 Timer,
18 Custom(String),
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct Signal {
25 pub kind: SignalKind,
27 pub payload: Value,
29}
30
31impl Signal {
32 #[must_use]
34 pub const fn new(kind: SignalKind, payload: Value) -> Self {
35 Self { kind, payload }
36 }
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41#[non_exhaustive]
42pub enum Action {
43 Continue,
45 GracefulStop,
47 ForceStop,
49 Transition(String),
51 Custom(String),
53}
54
55#[derive(Debug, Clone)]
57pub struct SignalRoute {
58 pub kind: SignalKind,
60 pub predicate: Option<fn(&Signal) -> bool>,
64 pub action: Action,
66 pub priority: i32,
68}
69
70impl SignalRoute {
71 #[must_use]
73 pub fn new(kind: SignalKind, action: Action, priority: i32) -> Self {
74 Self {
75 kind,
76 predicate: None,
77 action,
78 priority,
79 }
80 }
81
82 #[must_use]
84 pub fn with_predicate(
85 kind: SignalKind,
86 predicate: fn(&Signal) -> bool,
87 action: Action,
88 priority: i32,
89 ) -> Self {
90 Self {
91 kind,
92 predicate: Some(predicate),
93 action,
94 priority,
95 }
96 }
97
98 #[must_use]
100 pub fn matches(&self, signal: &Signal) -> bool {
101 if self.kind != signal.kind {
102 return false;
103 }
104 self.predicate.is_none_or(|pred| pred(signal))
105 }
106}
107
108pub trait SignalRouter: Send + Sync {
110 fn route(&self, signal: &Signal) -> Option<Action>;
112
113 fn routes(&self) -> Vec<SignalRoute>;
115}
116
117#[derive(Debug, Clone)]
122#[allow(clippy::struct_field_names)]
123pub struct ComposedRouter {
124 strategy_routes: Vec<SignalRoute>,
125 agent_routes: Vec<SignalRoute>,
126 plugin_routes: Vec<SignalRoute>,
127}
128
129impl ComposedRouter {
130 #[must_use]
132 pub const fn new(
133 strategy_routes: Vec<SignalRoute>,
134 agent_routes: Vec<SignalRoute>,
135 plugin_routes: Vec<SignalRoute>,
136 ) -> Self {
137 Self {
138 strategy_routes,
139 agent_routes,
140 plugin_routes,
141 }
142 }
143
144 fn best_match<'a>(signal: &Signal, routes: &'a [SignalRoute]) -> Option<&'a SignalRoute> {
145 routes
146 .iter()
147 .filter(|r| r.matches(signal))
148 .max_by_key(|r| r.priority)
149 }
150}
151
152impl SignalRouter for ComposedRouter {
153 fn route(&self, signal: &Signal) -> Option<Action> {
154 if let Some(route) = Self::best_match(signal, &self.strategy_routes) {
156 tracing::debug!(
157 kind = ?signal.kind,
158 priority = route.priority,
159 tier = "strategy",
160 "Signal routed"
161 );
162 return Some(route.action.clone());
163 }
164
165 if let Some(route) = Self::best_match(signal, &self.agent_routes) {
167 tracing::debug!(
168 kind = ?signal.kind,
169 priority = route.priority,
170 tier = "agent",
171 "Signal routed"
172 );
173 return Some(route.action.clone());
174 }
175
176 if let Some(route) = Self::best_match(signal, &self.plugin_routes) {
178 tracing::debug!(
179 kind = ?signal.kind,
180 priority = route.priority,
181 tier = "plugin",
182 "Signal routed"
183 );
184 return Some(route.action.clone());
185 }
186
187 tracing::debug!(kind = ?signal.kind, "No route found for signal");
188 None
189 }
190
191 fn routes(&self) -> Vec<SignalRoute> {
192 let mut all = self.strategy_routes.clone();
193 all.extend(self.agent_routes.clone());
194 all.extend(self.plugin_routes.clone());
195 all
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use serde_json::json;
203
204 fn stop_signal() -> Signal {
205 Signal::new(SignalKind::Stop, json!(null))
206 }
207
208 fn user_signal() -> Signal {
209 Signal::new(SignalKind::UserMessage, json!("hello"))
210 }
211
212 #[test]
213 fn test_strategy_route_wins_over_agent() {
214 let strategy = vec![SignalRoute::new(SignalKind::Stop, Action::ForceStop, 0)];
215 let agent = vec![SignalRoute::new(
216 SignalKind::Stop,
217 Action::GracefulStop,
218 100,
219 )];
220 let router = ComposedRouter::new(strategy, agent, vec![]);
221
222 let action = router.route(&stop_signal());
223 assert!(matches!(action, Some(Action::ForceStop)));
224 }
225
226 #[test]
227 fn test_agent_route_wins_over_plugin() {
228 let agent = vec![SignalRoute::new(SignalKind::Stop, Action::GracefulStop, 0)];
229 let plugin = vec![SignalRoute::new(SignalKind::Stop, Action::Continue, 100)];
230 let router = ComposedRouter::new(vec![], agent, plugin);
231
232 let action = router.route(&stop_signal());
233 assert!(matches!(action, Some(Action::GracefulStop)));
234 }
235
236 #[test]
237 fn test_higher_priority_wins_within_tier() {
238 let agent = vec![
239 SignalRoute::new(SignalKind::Stop, Action::GracefulStop, 10),
240 SignalRoute::new(SignalKind::Stop, Action::ForceStop, 20),
241 ];
242 let router = ComposedRouter::new(vec![], agent, vec![]);
243 let action = router.route(&stop_signal());
244 assert!(matches!(action, Some(Action::ForceStop)));
245 }
246
247 #[test]
248 fn test_predicate_filtering() {
249 fn only_nonempty(s: &Signal) -> bool {
250 s.payload.as_str().is_some_and(|v| !v.is_empty())
251 }
252
253 let agent = vec![
254 SignalRoute::with_predicate(
255 SignalKind::UserMessage,
256 only_nonempty,
257 Action::Continue,
258 10,
259 ),
260 SignalRoute::new(SignalKind::UserMessage, Action::GracefulStop, 0),
261 ];
262 let router = ComposedRouter::new(vec![], agent, vec![]);
263
264 let action = router.route(&user_signal());
266 assert!(matches!(action, Some(Action::Continue)));
267
268 let empty = Signal::new(SignalKind::UserMessage, json!(""));
270 let action = router.route(&empty);
271 assert!(matches!(action, Some(Action::GracefulStop)));
272 }
273
274 #[test]
275 fn test_no_route_returns_none() {
276 let router = ComposedRouter::new(vec![], vec![], vec![]);
277 assert!(router.route(&stop_signal()).is_none());
278 }
279}