Skip to main content

zeroclaw_runtime/
peers.rs

1//! Peer-group runtime resolution.
2//!
3//! Given a `Config` and an `agent_alias`, produces the effective set
4//! of peers that agent should accept inbound messages from on its
5//! configured channels. The schema-side primitive is the
6//! `[peer_groups.<name>]` block in `zeroclaw-config::multi_agent`;
7//! this module is the read-side resolver that walks the configured
8//! groups, applies the mutual-membership rule, unions external peers,
9//! subtracts the per-group ignore lists, and returns the result keyed
10//! by channel.
11//!
12//! Cross-reference invariants (peer-group members are configured
13//! agents, the group's channel is on each member's `channels` list)
14//! are upheld at config load. By the time the runtime calls
15//! [`resolve_peer_set`], every input is internally consistent.
16
17use std::collections::{BTreeMap, BTreeSet};
18use zeroclaw_config::schema::Config;
19
20/// Effective peer set for one agent, keyed by channel type.
21#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct ResolvedPeers {
23    /// Channel type → peer-agent aliases (bound agent excluded).
24    pub agent_peers: BTreeMap<String, BTreeSet<String>>,
25    /// Channel type → external-peer usernames (case-folded).
26    pub external_peers: BTreeMap<String, BTreeSet<String>>,
27}
28
29impl ResolvedPeers {
30    /// Whether the bound agent recognizes `target` as a peer on a
31    /// channel of `channel_type`. Outbound gate: unknown returns false.
32    #[must_use]
33    pub fn is_known_peer(&self, channel_type: &str, target: &str) -> bool {
34        let normalized = target.trim_start_matches('@').to_ascii_lowercase();
35        if let Some(agent_set) = self.agent_peers.get(channel_type)
36            && agent_set.contains(&normalized)
37        {
38            return true;
39        }
40        if let Some(ext_set) = self.external_peers.get(channel_type)
41            && ext_set.contains(&normalized)
42        {
43            return true;
44        }
45        false
46    }
47
48    /// NOT a security gate. Unknown senders return `true` by design;
49    /// peer groups are an additive routing hint for cross-agent traffic,
50    /// not a global inbound allowlist. Callers must have already
51    /// authenticated the sender (channel auth, signed webhook, etc.)
52    /// before reaching this check.
53    #[must_use]
54    pub fn allows_inbound(&self, channel_type: &str, origin: &str) -> bool {
55        let normalized = origin.trim_start_matches('@').to_ascii_lowercase();
56        if let Some(agent_set) = self.agent_peers.get(channel_type)
57            && agent_set.contains(&normalized)
58        {
59            return true;
60        }
61        if let Some(ext_set) = self.external_peers.get(channel_type)
62            && ext_set.contains(&normalized)
63        {
64            return true;
65        }
66        true
67    }
68}
69
70/// Defense-in-depth self-loop guard for the agent loop entry point.
71///
72/// Returns `true` when `sender` is recognizable as the bot's own
73/// outbound identity on this channel and the agent loop should refuse
74/// to spawn a turn. Mirrors `Channel::drop_self_messages`'s
75/// normalization (strip leading `@`, case-insensitive) so the two
76/// layers agree on what "self" means; the agent-loop call is a
77/// fallback for channel impls that route around the SDK guard or that
78/// expose self-identity later in their lifecycle than the
79/// orchestrator's check fires.
80#[must_use]
81pub fn should_drop_self_loop(sender: &str, self_handle: Option<&str>) -> bool {
82    let Some(handle) = self_handle else {
83        return false;
84    };
85    let handle_norm = handle.trim_start_matches('@').to_ascii_lowercase();
86    let sender_norm = sender.trim_start_matches('@').to_ascii_lowercase();
87    !handle_norm.is_empty() && handle_norm == sender_norm
88}
89
90/// Build the effective peer set for `agent_alias`.
91///
92/// Walks every `[peer_groups.<name>]` entry the agent appears in:
93///
94/// 1. Other agents in the same group (mutual membership) become peers
95///    on the group's channel.
96/// 2. The group's `external_peers` are added on the group's channel.
97/// 3. The group's `ignore` list is subtracted from both sets.
98/// 4. The bound agent's own alias is removed defensively (a misconfig
99///    that lists the agent in its own group's external_peers is the
100///    classic self-loop footgun the channel SDK already drops at the
101///    other end).
102///
103/// Returns an empty [`ResolvedPeers`] when the agent isn't on any
104/// peer group — the agent runs solo with no cross-agent dispatch.
105#[must_use]
106pub fn resolve_peer_set(config: &Config, agent_alias: &str) -> ResolvedPeers {
107    let mut resolved = ResolvedPeers::default();
108
109    for group in config.peer_groups.values() {
110        let on_group = group.agents.iter().any(|a| a.as_str() == agent_alias);
111        if !on_group {
112            continue;
113        }
114
115        let channel = group.channel.clone();
116        let agent_set = resolved.agent_peers.entry(channel.clone()).or_default();
117        // Aliases are stored case-folded so the lookup side
118        // (`is_known_peer` / `allows_inbound`) can normalize without
119        // missing `@Beta` against a config of `[agents.beta]` or
120        // similar. Aliases are config map keys — the schema does not
121        // enforce a case rule, so we match insensitively.
122        let self_norm = agent_alias.trim_start_matches('@').to_ascii_lowercase();
123        for member in &group.agents {
124            let normalized = member.as_str().trim_start_matches('@').to_ascii_lowercase();
125            if normalized != self_norm {
126                agent_set.insert(normalized);
127            }
128        }
129
130        let ext_set = resolved.external_peers.entry(channel.clone()).or_default();
131        for ext in &group.external_peers {
132            // PeerUsername is already case-folded and `@`-stripped at
133            // deserialization (multi_agent.rs).
134            ext_set.insert(ext.as_str().to_ascii_lowercase());
135        }
136
137        for ignored in &group.ignore {
138            let needle = ignored
139                .as_str()
140                .trim_start_matches('@')
141                .to_ascii_lowercase();
142            ext_set.remove(&needle);
143            agent_set.remove(&needle);
144        }
145    }
146
147    resolved
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn should_drop_self_loop_returns_false_when_handle_unknown() {
156        assert!(!should_drop_self_loop("@anyone", None));
157    }
158
159    #[test]
160    fn should_drop_self_loop_matches_normalized_handle() {
161        assert!(should_drop_self_loop("@my_bot", Some("@my_bot")));
162        assert!(should_drop_self_loop("@MY_BOT", Some("my_bot")));
163        assert!(should_drop_self_loop("my_bot", Some("@My_Bot")));
164        assert!(!should_drop_self_loop("@other_bot", Some("@my_bot")));
165    }
166
167    #[test]
168    fn should_drop_self_loop_ignores_empty_handle_after_normalization() {
169        // A handle of "@" (empty after stripping the @) must not match
170        // every inbound; the guard only fires on a real handle.
171        assert!(!should_drop_self_loop("@anyone", Some("@")));
172    }
173}