Skip to main content

supercode_harness/
routes.rs

1//! ORCH-15 (observed tier): the routing noun — which profile / agent a
2//! surface tuple resolves to.
3//!
4//! * **Hermes** — `gateway.profile_routes` in `HERMES_HOME/config.yaml`: a list
5//!   of `{platform, guild_id?, chat_id?, thread_id?, profile}` entries, most
6//!   specific wins (thread 8 > chat 4 > guild 2 > platform 0), the default
7//!   profile otherwise (`docs/HERMES-IDEAL-SUPPORT-DESIGN.md` §1).
8//! * **OpenClaw** — `bindings[]` in `openclaw.json`: `{agentId, match{channel,
9//!   accountId, peer{kind,id}, guildId, teamId, roles}}`, evaluated exact peer →
10//!   parent peer → wildcard peer → guild+roles → guild → team → account →
11//!   channel → default agent (§1b).
12//!
13//! Read-only. Editing a route stays the harness's own config edit.
14
15use std::collections::BTreeMap;
16use std::path::Path;
17
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20
21use crate::catalog::HarnessHomes;
22use crate::profiles::{read_json5, yaml_child, yaml_key};
23use crate::HarnessId;
24
25/// Wire schema of `harness.v1.routes.list`.
26pub const ROUTES_SCHEMA: &str = "supercode.routes.v1";
27
28/// Harnesses with a routing concept.
29pub const ROUTE_HARNESSES: &[&str] = &[
30    HarnessId::HERMES,
31    HarnessId::OPENCLAW,
32    HarnessId::ORCHESTRATOR,
33];
34
35/// The match side of a route, in the shared-noun vocabulary.
36#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
37pub struct RouteMatch {
38    /// Transport / platform (`slack`, `telegram`, …), or `None` for a catch-all.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub platform: Option<String>,
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub account: Option<String>,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub guild: Option<String>,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub team: Option<String>,
47    /// Chat / channel / group id, or the peer id (OpenClaw).
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub chat_id: Option<String>,
50    /// OpenClaw peer kind (`user` | `channel` | `group` | `thread`).
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub peer_kind: Option<String>,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub thread_id: Option<String>,
55    #[serde(default, skip_serializing_if = "Vec::is_empty")]
56    pub roles: Vec<String>,
57}
58
59/// One routing entry.
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub struct RouteRow {
62    pub harness: String,
63    /// Target profile (Hermes) or agent id (OpenClaw).
64    pub target: String,
65    #[serde(rename = "match")]
66    pub matcher: RouteMatch,
67    /// The harness's own precedence rank; higher wins.
68    pub specificity: u32,
69    /// The fallback route (no match fields).
70    pub default: bool,
71    /// Config file the route was read from.
72    pub source: String,
73}
74
75/// Why a listing was refused.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum RouteError {
78    /// The harness has no routing concept.
79    UnsupportedHarness { harness: String },
80}
81
82impl std::fmt::Display for RouteError {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        match self {
85            RouteError::UnsupportedHarness { harness } => write!(
86                f,
87                "`{harness}` has no routing concept; `routes.list` is supported for: {}",
88                ROUTE_HARNESSES.join(", ")
89            ),
90        }
91    }
92}
93
94impl std::error::Error for RouteError {}
95
96/// List routes, optionally for one harness and/or one target.
97pub fn list_routes(
98    homes: &HarnessHomes,
99    harness: Option<&str>,
100    target: Option<&str>,
101) -> Result<Vec<RouteRow>, RouteError> {
102    let harnesses: Vec<&str> = match harness {
103        Some(id) if ROUTE_HARNESSES.contains(&id) => vec![id],
104        Some(id) => {
105            return Err(RouteError::UnsupportedHarness {
106                harness: id.to_string(),
107            })
108        }
109        None => ROUTE_HARNESSES.to_vec(),
110    };
111    let mut rows = Vec::new();
112    for id in harnesses {
113        match id {
114            HarnessId::HERMES => rows.extend(hermes_rows(
115                HarnessId::HERMES,
116                homes.hermes.parent().unwrap_or(Path::new(".")),
117                true,
118            )),
119            HarnessId::OPENCLAW => rows.extend(openclaw_rows(&homes.openclaw)),
120            // ORC-7: the orchestrator writes `gateway.profile_routes` into
121            // each profile folder's own `config.yaml`, so the same reader
122            // runs once per folder. The unmatched-message default row is the
123            // ROOT folder's, emitted once — a named profile's config does not
124            // get to claim it.
125            HarnessId::ORCHESTRATOR => {
126                for (name, dir) in crate::orchestrator_profile_dirs(&homes.orchestrator) {
127                    rows.extend(hermes_rows(
128                        HarnessId::ORCHESTRATOR,
129                        &dir,
130                        name == "default",
131                    ));
132                }
133            }
134            _ => {}
135        }
136    }
137    if let Some(target) = target {
138        rows.retain(|row| row.target == target);
139    }
140    rows.sort_by(|a, b| {
141        a.harness
142            .cmp(&b.harness)
143            .then(b.specificity.cmp(&a.specificity))
144            .then(a.target.cmp(&b.target))
145    });
146    Ok(rows)
147}
148
149/// Read `gateway.profile_routes` out of one Hermes-shaped home.
150///
151/// `home` is the folder holding `config.yaml` (Hermes: HERMES_HOME, the
152/// parent of `HarnessHomes::hermes`; the orchestrator: one profile folder).
153/// `with_default` emits the catch-all row for the home that owns unmatched
154/// traffic.
155fn hermes_rows(harness: &str, home: &Path, with_default: bool) -> Vec<RouteRow> {
156    let config_path = home.join("config.yaml");
157    let Ok(config) = std::fs::read_to_string(&config_path) else {
158        return Vec::new();
159    };
160    let source = config_path.display().to_string();
161    let gateway = yaml_child(&config, "gateway");
162    let block = yaml_child(&gateway, "profile_routes");
163    let mut rows = Vec::new();
164    let mut current: Option<BTreeMap<String, String>> = None;
165    let flush = |entry: Option<BTreeMap<String, String>>, rows: &mut Vec<RouteRow>| {
166        let Some(entry) = entry else { return };
167        let Some(profile) = entry.get("profile").filter(|p| !p.is_empty()) else {
168            return;
169        };
170        let matcher = RouteMatch {
171            platform: entry.get("platform").cloned(),
172            guild: entry.get("guild_id").cloned(),
173            chat_id: entry.get("chat_id").cloned(),
174            thread_id: entry.get("thread_id").cloned(),
175            ..RouteMatch::default()
176        };
177        // Hermes's own weights: thread 8, channel/chat 4, guild 2, platform-only 0.
178        let specificity = matcher.thread_id.as_ref().map_or(0, |_| 8)
179            + matcher.chat_id.as_ref().map_or(0, |_| 4)
180            + matcher.guild.as_ref().map_or(0, |_| 2);
181        rows.push(RouteRow {
182            harness: harness.into(),
183            target: profile.clone(),
184            matcher,
185            specificity,
186            default: false,
187            source: source.clone(),
188        });
189    };
190    for line in block.lines() {
191        let trimmed = line.trim_start();
192        if trimmed.is_empty() || trimmed.starts_with('#') {
193            continue;
194        }
195        let (body, starts_entry) = match trimmed.strip_prefix("- ") {
196            Some(rest) => (rest, true),
197            None => (trimmed, false),
198        };
199        if starts_entry {
200            flush(current.take(), &mut rows);
201            current = Some(BTreeMap::new());
202        }
203        let (Some(key), Some(value)) = (yaml_key(body), yaml_scalar_value(body)) else {
204            continue;
205        };
206        current
207            .get_or_insert_with(BTreeMap::new)
208            .insert(key.to_string(), value);
209    }
210    flush(current.take(), &mut rows);
211    // The default profile is the home itself; every unmatched message lands there.
212    if with_default {
213        rows.push(RouteRow {
214            harness: harness.into(),
215            target: "default".into(),
216            matcher: RouteMatch::default(),
217            specificity: 0,
218            default: true,
219            source,
220        });
221    }
222    rows
223}
224
225fn yaml_scalar_value(line: &str) -> Option<String> {
226    let (_, tail) = line.split_once(':')?;
227    let tail = tail.trim();
228    let tail = tail.split_once(" #").map(|(head, _)| head).unwrap_or(tail);
229    Some(
230        tail.trim()
231            .trim_matches(|ch| ch == '"' || ch == '\'')
232            .to_string(),
233    )
234}
235
236/// OpenClaw: `bindings[]` + the default agent from `agents.list` /
237/// `agents.entries`.
238fn openclaw_rows(home: &Path) -> Vec<RouteRow> {
239    let config_path = home.join("openclaw.json");
240    let config = read_json5(&config_path);
241    if config.is_null() {
242        return Vec::new();
243    }
244    let source = config_path.display().to_string();
245    let mut rows = Vec::new();
246    if let Some(bindings) = config.pointer("/bindings").and_then(Value::as_array) {
247        for binding in bindings {
248            let Some(agent) = binding.get("agentId").and_then(Value::as_str) else {
249                continue;
250            };
251            let m = binding.get("match").cloned().unwrap_or(Value::Null);
252            let text = |key: &str| m.get(key).and_then(Value::as_str).map(str::to_string);
253            let peer = m.get("peer").cloned().unwrap_or(Value::Null);
254            let peer_id = peer.get("id").and_then(Value::as_str).map(str::to_string);
255            let peer_kind = peer.get("kind").and_then(Value::as_str).map(str::to_string);
256            let roles: Vec<String> = m
257                .get("roles")
258                .and_then(Value::as_array)
259                .map(|list| {
260                    list.iter()
261                        .filter_map(Value::as_str)
262                        .map(str::to_string)
263                        .collect()
264                })
265                .unwrap_or_default();
266            let guild = text("guildId");
267            let team = text("teamId");
268            let account = text("accountId");
269            let channel = text("channel");
270            // The documented cascade, ranked so higher wins: exact peer 8,
271            // parent peer 7, wildcard peer 6, guild+roles 5, guild 4, team 3,
272            // account 2, channel 1, default 0.
273            let specificity = match (&peer_id, &peer_kind) {
274                (Some(id), _) if id == "*" => 6,
275                (Some(_), Some(kind)) if kind == "parent" => 7,
276                (Some(_), _) => 8,
277                _ if guild.is_some() && !roles.is_empty() => 5,
278                _ if guild.is_some() => 4,
279                _ if team.is_some() => 3,
280                _ if account.is_some() => 2,
281                _ if channel.is_some() => 1,
282                _ => 0,
283            };
284            rows.push(RouteRow {
285                harness: HarnessId::OPENCLAW.into(),
286                target: agent.to_string(),
287                matcher: RouteMatch {
288                    platform: channel,
289                    account,
290                    guild,
291                    team,
292                    chat_id: peer_id,
293                    peer_kind,
294                    thread_id: None,
295                    roles,
296                },
297                specificity,
298                default: false,
299                source: source.clone(),
300            });
301        }
302    }
303    let default_agent = openclaw_default_agent(&config).unwrap_or_else(|| "main".into());
304    rows.push(RouteRow {
305        harness: HarnessId::OPENCLAW.into(),
306        target: default_agent,
307        matcher: RouteMatch::default(),
308        specificity: 0,
309        default: true,
310        source,
311    });
312    rows
313}
314
315fn openclaw_default_agent(config: &Value) -> Option<String> {
316    if let Some(list) = config.pointer("/agents/list").and_then(Value::as_array) {
317        let flagged = list
318            .iter()
319            .find(|entry| entry.get("default").and_then(Value::as_bool) == Some(true))
320            .or_else(|| list.first());
321        return flagged
322            .and_then(|entry| entry.get("id").and_then(Value::as_str))
323            .map(str::to_string);
324    }
325    if let Some(entries) = config.pointer("/agents/entries").and_then(Value::as_object) {
326        let flagged = entries
327            .iter()
328            .find(|(_, entry)| entry.get("default").and_then(Value::as_bool) == Some(true))
329            .or_else(|| entries.iter().next());
330        return flagged.map(|(id, _)| id.clone());
331    }
332    None
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    fn scratch(tag: &str) -> std::path::PathBuf {
340        let dir = std::env::temp_dir().join(format!(
341            "supercode-routes-{tag}-{}-{}",
342            std::process::id(),
343            std::time::SystemTime::now()
344                .duration_since(std::time::UNIX_EPOCH)
345                .unwrap()
346                .as_nanos()
347        ));
348        std::fs::create_dir_all(&dir).unwrap();
349        dir
350    }
351
352    #[test]
353    fn hermes_routes_parse_entries_and_weight_them() {
354        let dir = scratch("hermes");
355        std::fs::write(
356            dir.join("config.yaml"),
357            "gateway:\n  profile_routes:\n    - platform: slack\n      chat_id: C1\n      thread_id: T9\n      profile: coder\n    - platform: telegram\n      profile: ops # comment\n",
358        )
359        .unwrap();
360        let rows = hermes_rows(HarnessId::HERMES, &dir, true);
361        assert_eq!(rows.len(), 3);
362        assert_eq!(rows[0].target, "coder");
363        assert_eq!(rows[0].specificity, 12);
364        assert_eq!(rows[0].matcher.thread_id.as_deref(), Some("T9"));
365        assert_eq!(rows[1].target, "ops");
366        assert_eq!(rows[1].specificity, 0);
367        assert!(rows[2].default);
368    }
369
370    /// ORC-7: the orchestrator's routes come from every profile folder's own
371    /// `config.yaml`, and the unmatched-traffic default row belongs to the
372    /// ROOT folder alone — a named profile's table does not get to claim it.
373    #[test]
374    fn orchestrator_routes_are_read_per_profile_folder_with_one_default() {
375        let dir = scratch("orchestrator");
376        std::fs::create_dir_all(dir.join("profiles/ops")).unwrap();
377        std::fs::write(
378            dir.join("config.yaml"),
379            "gateway:\n  profile_routes:\n    - platform: slack\n      chat_id: C1\n      profile: ops\n",
380        )
381        .unwrap();
382        std::fs::write(
383            dir.join("profiles/ops/config.yaml"),
384            "gateway:\n  profile_routes:\n    - platform: telegram\n      profile: ops\n",
385        )
386        .unwrap();
387        let homes = HarnessHomes {
388            orchestrator: dir.clone(),
389            ..HarnessHomes::default()
390        };
391        let rows = list_routes(&homes, Some(HarnessId::ORCHESTRATOR), None).unwrap();
392        assert!(
393            rows.iter()
394                .all(|row| row.harness == HarnessId::ORCHESTRATOR),
395            "{rows:?}"
396        );
397        assert_eq!(rows.iter().filter(|row| row.default).count(), 1, "{rows:?}");
398        // Both folders' tables are read: two routes to `ops` (the root's
399        // slack one and the named profile's telegram one) plus the default.
400        assert_eq!(
401            rows.iter().filter(|row| row.target == "ops").count(),
402            2,
403            "{rows:?}"
404        );
405        // Hermes's own weights, unchanged: chat 4, platform-only 0. (Rows are
406        // ordered by specificity then target, which is the existing shared
407        // ordering — the default is not pinned last on a specificity tie.)
408        assert_eq!(rows[0].specificity, 4);
409        assert_eq!(rows[0].matcher.chat_id.as_deref(), Some("C1"));
410        assert!(
411            rows.iter()
412                .any(|row| row.matcher.platform.as_deref() == Some("telegram")
413                    && row.specificity == 0)
414        );
415        std::fs::remove_dir_all(&dir).ok();
416    }
417
418    #[test]
419    fn openclaw_bindings_follow_the_documented_cascade() {
420        let dir = scratch("openclaw");
421        std::fs::write(
422            dir.join("openclaw.json"),
423            r#"{ "agents": { "list": [ { "id": "main", "default": true }, { "id": "design" } ] },
424                "bindings": [
425                  { "type": "route", "agentId": "design", "match": { "channel": "slack" } },
426                  { "type": "route", "agentId": "ops", "match": { "channel": "discord", "guildId": "G1", "roles": ["admin"] } },
427                  { "type": "route", "agentId": "vip", "match": { "channel": "telegram", "peer": { "kind": "user", "id": "U1" } } }
428                ] }"#,
429        )
430        .unwrap();
431        let rows = openclaw_rows(&dir);
432        let spec: Vec<(String, u32)> = rows
433            .iter()
434            .map(|r| (r.target.clone(), r.specificity))
435            .collect();
436        assert_eq!(
437            spec,
438            vec![
439                ("design".into(), 1),
440                ("ops".into(), 5),
441                ("vip".into(), 8),
442                ("main".into(), 0)
443            ]
444        );
445        assert!(rows[3].default);
446    }
447
448    #[test]
449    fn unsupported_harness_is_refused() {
450        let err = list_routes(&HarnessHomes::default(), Some("codex"), None).unwrap_err();
451        assert!(err.to_string().contains("routes.list"));
452    }
453}