Skip to main content

synwire_core/agents/
signal.rs

1//! Signal routing for agent communication.
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6/// Signal kind category.
7#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
8#[non_exhaustive]
9pub enum SignalKind {
10    /// User requested stop.
11    Stop,
12    /// User message received.
13    UserMessage,
14    /// Tool invocation result.
15    ToolResult,
16    /// Timer / cron event.
17    Timer,
18    /// Custom signal kind.
19    Custom(String),
20}
21
22/// Signal sent to an agent.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct Signal {
25    /// Signal kind.
26    pub kind: SignalKind,
27    /// Signal payload.
28    pub payload: Value,
29}
30
31impl Signal {
32    /// Create a new signal.
33    #[must_use]
34    pub const fn new(kind: SignalKind, payload: Value) -> Self {
35        Self { kind, payload }
36    }
37}
38
39/// Action to take in response to a signal.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41#[non_exhaustive]
42pub enum Action {
43    /// Continue processing normally.
44    Continue,
45    /// Stop the agent gracefully (drain in-flight work first).
46    GracefulStop,
47    /// Stop the agent immediately.
48    ForceStop,
49    /// Transition to a new FSM state.
50    Transition(String),
51    /// Custom action identifier.
52    Custom(String),
53}
54
55/// A route mapping a signal kind (with optional predicate) to an action.
56#[derive(Debug, Clone)]
57pub struct SignalRoute {
58    /// Signal kind this route handles.
59    pub kind: SignalKind,
60    /// Optional predicate for additional filtering.
61    ///
62    /// Uses a function pointer so `SignalRoute` remains `Clone + Send + Sync`.
63    pub predicate: Option<fn(&Signal) -> bool>,
64    /// Action to take when route matches.
65    pub action: Action,
66    /// Priority: higher value wins when multiple routes match.
67    pub priority: i32,
68}
69
70impl SignalRoute {
71    /// Create a new signal route without a predicate.
72    #[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    /// Create a route with an additional predicate.
83    #[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    /// Returns `true` if this route matches the given signal.
99    #[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
108/// Routes signals to actions.
109pub trait SignalRouter: Send + Sync {
110    /// Route a signal, returning the best-matching action if any.
111    fn route(&self, signal: &Signal) -> Option<Action>;
112
113    /// All routes contributed by this router.
114    fn routes(&self) -> Vec<SignalRoute>;
115}
116
117/// Composed router across three priority tiers: strategy > agent > plugin.
118///
119/// Within each tier, the route with the highest `priority` value wins.
120/// Strategy-tier routes always beat agent-tier routes regardless of priority value.
121#[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    /// Create a new composed router.
131    #[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        // Strategy routes have the highest tier precedence.
155        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        // Agent routes are second.
166        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        // Plugin routes are lowest.
177        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        // Non-empty payload: predicate matches, higher priority wins.
265        let action = router.route(&user_signal());
266        assert!(matches!(action, Some(Action::Continue)));
267
268        // Empty payload: predicate fails, falls back to lower-priority route.
269        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}