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}