Skip to main content

mur_common/bridge/
routes.rs

1//! `routes.yaml` schema for the A2A bridge.
2//!
3//! A bridge is an LLM-less mur agent that ferries chat-platform messages to
4//! the right *user* agent on the local A2A bus. `BridgeRouteConfig` describes
5//! a deterministic mapping from inbound message → recipient agent(s) with the
6//! precedence: explicit mention > platform-specific match (chat_id) >
7//! default_route. There is **no LLM triage** in routing — the resolver is a
8//! pure function over the inbound envelope and the static config.
9
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct BridgeRouteConfig {
14    pub default_route: String,
15    #[serde(default)]
16    pub routes: Vec<RouteEntry>,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct RouteEntry {
21    #[serde(rename = "match")]
22    pub match_: RouteMatch,
23    pub agent: String,
24    #[serde(default, skip_serializing_if = "Vec::is_empty")]
25    pub fanout: Vec<String>,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct RouteMatch {
30    pub platform: String,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub mention: Option<String>,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub chat_id: Option<String>,
35}
36
37#[derive(Debug, Clone)]
38pub struct InboundMessage {
39    pub platform: String,
40    pub chat_id: String,
41    pub body: String,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct Resolution {
46    primary: String,
47    fanout: Vec<String>,
48}
49
50impl Resolution {
51    pub fn recipients(&self) -> Vec<String> {
52        if self.fanout.is_empty() {
53            vec![self.primary.clone()]
54        } else {
55            self.fanout.clone()
56        }
57    }
58}
59
60impl BridgeRouteConfig {
61    pub fn resolve(&self, inbound: &InboundMessage) -> Resolution {
62        // Pass 1: mention (highest priority)
63        for entry in &self.routes {
64            if entry.match_.platform != inbound.platform {
65                continue;
66            }
67            if let Some(m) = &entry.match_.mention
68                && inbound.body.contains(m.as_str())
69            {
70                return Resolution {
71                    primary: entry.agent.clone(),
72                    fanout: entry.fanout.clone(),
73                };
74            }
75        }
76        // Pass 2: platform + chat_id (skip mention-routes; they only match in pass 1)
77        for entry in &self.routes {
78            if entry.match_.platform != inbound.platform {
79                continue;
80            }
81            if entry.match_.mention.is_some() {
82                continue;
83            }
84            if let Some(c) = &entry.match_.chat_id
85                && c == &inbound.chat_id
86            {
87                return Resolution {
88                    primary: entry.agent.clone(),
89                    fanout: entry.fanout.clone(),
90                };
91            }
92        }
93        // Pass 3: default
94        Resolution {
95            primary: self.default_route.clone(),
96            fanout: vec![],
97        }
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    const SAMPLE: &str = r#"
105default_route: coach
106routes:
107  - match: { platform: telegram, mention: "@coach" }
108    agent: coach
109  - match: { platform: telegram, chat_id: "12345" }
110    agent: therapist
111  - match: { platform: telegram, chat_id: "67890" }
112    agent: coach
113    fanout: [coach, journal_agent]
114"#;
115    #[test]
116    fn parses_full_example() {
117        let cfg: BridgeRouteConfig = serde_yaml_ng::from_str(SAMPLE).unwrap();
118        assert_eq!(cfg.default_route, "coach");
119        assert_eq!(cfg.routes.len(), 3);
120    }
121    #[test]
122    fn round_trip_preserves_fields() {
123        let cfg: BridgeRouteConfig = serde_yaml_ng::from_str(SAMPLE).unwrap();
124        let s = serde_yaml_ng::to_string(&cfg).unwrap();
125        assert_eq!(
126            serde_yaml_ng::from_str::<BridgeRouteConfig>(&s).unwrap(),
127            cfg
128        );
129    }
130
131    #[test]
132    fn resolve_falls_back_to_default() {
133        let cfg: BridgeRouteConfig = serde_yaml_ng::from_str(SAMPLE).unwrap();
134        let r = cfg.resolve(&InboundMessage {
135            platform: "telegram".into(),
136            chat_id: "99999".into(),
137            body: "hello".into(),
138        });
139        assert_eq!(r.recipients(), vec!["coach"]);
140    }
141
142    #[test]
143    fn mention_wins_over_chat_id() {
144        let cfg: BridgeRouteConfig = serde_yaml_ng::from_str(SAMPLE).unwrap();
145        let r = cfg.resolve(&InboundMessage {
146            platform: "telegram".into(),
147            chat_id: "12345".into(),        // would route to therapist
148            body: "hey @coach help".into(), // mention wins
149        });
150        assert_eq!(r.recipients(), vec!["coach"]);
151    }
152
153    #[test]
154    fn chat_id_when_no_mention() {
155        let cfg: BridgeRouteConfig = serde_yaml_ng::from_str(SAMPLE).unwrap();
156        let r = cfg.resolve(&InboundMessage {
157            platform: "telegram".into(),
158            chat_id: "12345".into(),
159            body: "no mentions".into(),
160        });
161        assert_eq!(r.recipients(), vec!["therapist"]);
162    }
163
164    #[test]
165    fn fanout_returns_full_list() {
166        let cfg: BridgeRouteConfig = serde_yaml_ng::from_str(SAMPLE).unwrap();
167        let r = cfg.resolve(&InboundMessage {
168            platform: "telegram".into(),
169            chat_id: "67890".into(),
170            body: "ping".into(),
171        });
172        assert_eq!(r.recipients(), vec!["coach", "journal_agent"]);
173    }
174
175    #[test]
176    fn platform_mismatch_falls_through() {
177        let cfg: BridgeRouteConfig = serde_yaml_ng::from_str(SAMPLE).unwrap();
178        let r = cfg.resolve(&InboundMessage {
179            platform: "slack".into(),
180            chat_id: "12345".into(),
181            body: "ping".into(),
182        });
183        assert_eq!(r.recipients(), vec!["coach"]);
184    }
185}