Skip to main content

codetether_agent/bus/
registry.rs

1//! Agent registry — tracks connected agents and their cards.
2//!
3//! Every agent that joins the bus registers an `AgentCard`, which is stored
4//! in a concurrent `DashMap`.  The registry also provides an ephemeral card
5//! factory for sub-agents that need a short-lived identity.
6
7use crate::a2a::types::{AgentCapabilities, AgentCard, AgentSkill};
8use dashmap::DashMap;
9use uuid::Uuid;
10
11/// Thread-safe registry of agent cards keyed by agent id.
12pub struct AgentRegistry {
13    cards: DashMap<String, AgentCard>,
14}
15
16impl AgentRegistry {
17    /// Create an empty registry.
18    pub fn new() -> Self {
19        Self {
20            cards: DashMap::new(),
21        }
22    }
23
24    /// Register an agent card.  Overwrites any previous card for the same id.
25    pub fn register(&self, card: AgentCard) {
26        let id = card.name.clone();
27        tracing::info!(agent_id = %id, "Agent registered on bus");
28        self.cards.insert(id, card);
29    }
30
31    /// Register an agent from a protocol-level ready announcement.
32    ///
33    /// This normalizes the ready payload into an `AgentCard` so all components
34    /// (TUI, worker, swarm) can rely on the bus registry as source of truth.
35    pub fn register_ready(&self, agent_id: &str, capabilities: &[String]) -> AgentCard {
36        let skills = capabilities
37            .iter()
38            .map(|capability| {
39                let id = sanitize_skill_id(capability);
40                AgentSkill {
41                    id,
42                    name: capability.clone(),
43                    description: format!("Capability: {capability}"),
44                    tags: vec!["protocol".to_string(), "bus".to_string()],
45                    examples: vec![],
46                    input_modes: vec!["text".to_string()],
47                    output_modes: vec!["text".to_string()],
48                }
49            })
50            .collect::<Vec<_>>();
51
52        let card = AgentCard {
53            name: agent_id.to_string(),
54            description: format!("In-process agent {agent_id} (registered via AgentReady)"),
55            url: format!("bus://local/{agent_id}"),
56            version: "ephemeral".to_string(),
57            protocol_version: "0.3.0".to_string(),
58            preferred_transport: Some("BUS".to_string()),
59            additional_interfaces: vec![],
60            capabilities: AgentCapabilities {
61                streaming: true,
62                push_notifications: false,
63                state_transition_history: false,
64                extensions: vec![],
65            },
66            skills,
67            default_input_modes: vec!["text".to_string()],
68            default_output_modes: vec!["text".to_string()],
69            provider: None,
70            icon_url: None,
71            documentation_url: None,
72            security_schemes: Default::default(),
73            security: vec![],
74            supports_authenticated_extended_card: false,
75            signatures: vec![],
76        };
77
78        self.register(card.clone());
79        card
80    }
81
82    /// Deregister an agent by id.
83    pub fn deregister(&self, agent_id: &str) -> Option<AgentCard> {
84        tracing::info!(agent_id = %agent_id, "Agent deregistered from bus");
85        self.cards.remove(agent_id).map(|(_, card)| card)
86    }
87
88    /// Look up a card by agent id.
89    pub fn get(&self, agent_id: &str) -> Option<AgentCard> {
90        self.cards.get(agent_id).map(|r| r.value().clone())
91    }
92
93    /// List all registered agent ids.
94    pub fn agent_ids(&self) -> Vec<String> {
95        self.cards.iter().map(|r| r.key().clone()).collect()
96    }
97
98    /// Number of registered agents.
99    pub fn len(&self) -> usize {
100        self.cards.len()
101    }
102
103    /// Whether the registry is empty.
104    pub fn is_empty(&self) -> bool {
105        self.cards.is_empty()
106    }
107
108    /// Create and register an ephemeral card for a sub-agent.
109    ///
110    /// These cards are lightweight and intended for in-process sub-agents
111    /// that exist only for the lifetime of a swarm execution.  The URL
112    /// is set to `bus://local/{agent_id}` to signal that the agent is only
113    /// reachable through the in-process bus.
114    pub fn create_ephemeral(
115        &self,
116        name: impl Into<String>,
117        description: impl Into<String>,
118        skills: Vec<AgentSkill>,
119    ) -> AgentCard {
120        let name = name.into();
121        let card = AgentCard {
122            name: name.clone(),
123            description: description.into(),
124            url: format!("bus://local/{name}"),
125            version: "ephemeral".to_string(),
126            protocol_version: "0.3.0".to_string(),
127            preferred_transport: Some("BUS".to_string()),
128            additional_interfaces: vec![],
129            capabilities: AgentCapabilities {
130                streaming: true,
131                push_notifications: false,
132                state_transition_history: false,
133                extensions: vec![],
134            },
135            skills,
136            default_input_modes: vec!["text".to_string()],
137            default_output_modes: vec!["text".to_string()],
138            provider: None,
139            icon_url: None,
140            documentation_url: None,
141            security_schemes: Default::default(),
142            security: vec![],
143            supports_authenticated_extended_card: false,
144            signatures: vec![],
145        };
146        self.register(card.clone());
147        card
148    }
149
150    /// Create a unique ephemeral agent name.
151    pub fn ephemeral_name(prefix: &str) -> String {
152        let short_id = &Uuid::new_v4().to_string()[..8];
153        format!("{prefix}-{short_id}")
154    }
155}
156
157fn sanitize_skill_id(raw: &str) -> String {
158    let mut out = String::with_capacity(raw.len());
159    for ch in raw.chars() {
160        if ch.is_ascii_alphanumeric() {
161            out.push(ch.to_ascii_lowercase());
162        } else if !out.ends_with('-') {
163            out.push('-');
164        }
165    }
166
167    let trimmed = out.trim_matches('-');
168    if trimmed.is_empty() {
169        "capability".to_string()
170    } else {
171        trimmed.to_string()
172    }
173}
174
175impl Default for AgentRegistry {
176    fn default() -> Self {
177        Self::new()
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn test_register_and_lookup() {
187        let reg = AgentRegistry::new();
188        let card = reg.create_ephemeral("agent-1", "Test agent", vec![]);
189        assert_eq!(reg.len(), 1);
190
191        let found = reg.get("agent-1").unwrap();
192        assert_eq!(found.url, card.url);
193    }
194
195    #[test]
196    fn test_deregister() {
197        let reg = AgentRegistry::new();
198        reg.create_ephemeral("agent-2", "temp", vec![]);
199        assert_eq!(reg.len(), 1);
200
201        let removed = reg.deregister("agent-2");
202        assert!(removed.is_some());
203        assert_eq!(reg.len(), 0);
204    }
205
206    #[test]
207    fn test_ephemeral_name_unique() {
208        let a = AgentRegistry::ephemeral_name("sub");
209        let b = AgentRegistry::ephemeral_name("sub");
210        assert_ne!(a, b);
211    }
212
213    #[test]
214    fn test_agent_ids() {
215        let reg = AgentRegistry::new();
216        reg.create_ephemeral("alpha", "a", vec![]);
217        reg.create_ephemeral("beta", "b", vec![]);
218        let mut ids = reg.agent_ids();
219        ids.sort();
220        assert_eq!(ids, vec!["alpha", "beta"]);
221    }
222
223    #[test]
224    fn test_register_ready_creates_card_with_skills() {
225        let reg = AgentRegistry::new();
226        let caps = vec!["plan.tasks".to_string(), "tool.call".to_string()];
227        let card = reg.register_ready("agent-ready-1", &caps);
228
229        assert_eq!(card.name, "agent-ready-1");
230        assert_eq!(card.url, "bus://local/agent-ready-1");
231        assert_eq!(card.skills.len(), 2);
232        assert!(reg.get("agent-ready-1").is_some());
233    }
234}