use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum SignalKind {
Stop,
UserMessage,
ToolResult,
Timer,
Custom(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Signal {
pub kind: SignalKind,
pub payload: Value,
}
impl Signal {
#[must_use]
pub const fn new(kind: SignalKind, payload: Value) -> Self {
Self { kind, payload }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Action {
Continue,
GracefulStop,
ForceStop,
Transition(String),
Custom(String),
}
#[derive(Debug, Clone)]
pub struct SignalRoute {
pub kind: SignalKind,
pub predicate: Option<fn(&Signal) -> bool>,
pub action: Action,
pub priority: i32,
}
impl SignalRoute {
#[must_use]
pub fn new(kind: SignalKind, action: Action, priority: i32) -> Self {
Self {
kind,
predicate: None,
action,
priority,
}
}
#[must_use]
pub fn with_predicate(
kind: SignalKind,
predicate: fn(&Signal) -> bool,
action: Action,
priority: i32,
) -> Self {
Self {
kind,
predicate: Some(predicate),
action,
priority,
}
}
#[must_use]
pub fn matches(&self, signal: &Signal) -> bool {
if self.kind != signal.kind {
return false;
}
self.predicate.is_none_or(|pred| pred(signal))
}
}
pub trait SignalRouter: Send + Sync {
fn route(&self, signal: &Signal) -> Option<Action>;
fn routes(&self) -> Vec<SignalRoute>;
}
#[derive(Debug, Clone)]
#[allow(clippy::struct_field_names)]
pub struct ComposedRouter {
strategy_routes: Vec<SignalRoute>,
agent_routes: Vec<SignalRoute>,
plugin_routes: Vec<SignalRoute>,
}
impl ComposedRouter {
#[must_use]
pub const fn new(
strategy_routes: Vec<SignalRoute>,
agent_routes: Vec<SignalRoute>,
plugin_routes: Vec<SignalRoute>,
) -> Self {
Self {
strategy_routes,
agent_routes,
plugin_routes,
}
}
fn best_match<'a>(signal: &Signal, routes: &'a [SignalRoute]) -> Option<&'a SignalRoute> {
routes
.iter()
.filter(|r| r.matches(signal))
.max_by_key(|r| r.priority)
}
}
impl SignalRouter for ComposedRouter {
fn route(&self, signal: &Signal) -> Option<Action> {
if let Some(route) = Self::best_match(signal, &self.strategy_routes) {
tracing::debug!(
kind = ?signal.kind,
priority = route.priority,
tier = "strategy",
"Signal routed"
);
return Some(route.action.clone());
}
if let Some(route) = Self::best_match(signal, &self.agent_routes) {
tracing::debug!(
kind = ?signal.kind,
priority = route.priority,
tier = "agent",
"Signal routed"
);
return Some(route.action.clone());
}
if let Some(route) = Self::best_match(signal, &self.plugin_routes) {
tracing::debug!(
kind = ?signal.kind,
priority = route.priority,
tier = "plugin",
"Signal routed"
);
return Some(route.action.clone());
}
tracing::debug!(kind = ?signal.kind, "No route found for signal");
None
}
fn routes(&self) -> Vec<SignalRoute> {
let mut all = self.strategy_routes.clone();
all.extend(self.agent_routes.clone());
all.extend(self.plugin_routes.clone());
all
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn stop_signal() -> Signal {
Signal::new(SignalKind::Stop, json!(null))
}
fn user_signal() -> Signal {
Signal::new(SignalKind::UserMessage, json!("hello"))
}
#[test]
fn test_strategy_route_wins_over_agent() {
let strategy = vec![SignalRoute::new(SignalKind::Stop, Action::ForceStop, 0)];
let agent = vec![SignalRoute::new(
SignalKind::Stop,
Action::GracefulStop,
100,
)];
let router = ComposedRouter::new(strategy, agent, vec![]);
let action = router.route(&stop_signal());
assert!(matches!(action, Some(Action::ForceStop)));
}
#[test]
fn test_agent_route_wins_over_plugin() {
let agent = vec![SignalRoute::new(SignalKind::Stop, Action::GracefulStop, 0)];
let plugin = vec![SignalRoute::new(SignalKind::Stop, Action::Continue, 100)];
let router = ComposedRouter::new(vec![], agent, plugin);
let action = router.route(&stop_signal());
assert!(matches!(action, Some(Action::GracefulStop)));
}
#[test]
fn test_higher_priority_wins_within_tier() {
let agent = vec![
SignalRoute::new(SignalKind::Stop, Action::GracefulStop, 10),
SignalRoute::new(SignalKind::Stop, Action::ForceStop, 20),
];
let router = ComposedRouter::new(vec![], agent, vec![]);
let action = router.route(&stop_signal());
assert!(matches!(action, Some(Action::ForceStop)));
}
#[test]
fn test_predicate_filtering() {
fn only_nonempty(s: &Signal) -> bool {
s.payload.as_str().is_some_and(|v| !v.is_empty())
}
let agent = vec![
SignalRoute::with_predicate(
SignalKind::UserMessage,
only_nonempty,
Action::Continue,
10,
),
SignalRoute::new(SignalKind::UserMessage, Action::GracefulStop, 0),
];
let router = ComposedRouter::new(vec![], agent, vec![]);
let action = router.route(&user_signal());
assert!(matches!(action, Some(Action::Continue)));
let empty = Signal::new(SignalKind::UserMessage, json!(""));
let action = router.route(&empty);
assert!(matches!(action, Some(Action::GracefulStop)));
}
#[test]
fn test_no_route_returns_none() {
let router = ComposedRouter::new(vec![], vec![], vec![]);
assert!(router.route(&stop_signal()).is_none());
}
}