1mod card_source;
21mod peer;
22mod refresh;
23
24pub use card_source::{CardSource, CardSourceError, HttpCardSource, InMemoryCardSource};
25pub use peer::DiscoveredPeer;
26pub use refresh::{CardRefresher, DEFAULT_REFRESH_INTERVAL, RefreshReport};
27
28use std::collections::HashMap;
29use std::fmt;
30use std::str::FromStr;
31use std::sync::Arc;
32
33use async_trait::async_trait;
34use thiserror::Error;
35use tokio::sync::RwLock;
36
37use a2a_rs::domain::AgentCard;
38
39#[derive(Debug, Clone, PartialEq, Eq, Hash)]
45pub struct AgentId(String);
46
47impl AgentId {
48 pub fn from_name(name: &str) -> Self {
50 Self(crate::utils::slugify(name, '-'))
51 }
52
53 pub fn as_str(&self) -> &str {
55 &self.0
56 }
57}
58
59impl fmt::Display for AgentId {
60 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61 f.write_str(&self.0)
62 }
63}
64
65impl From<&str> for AgentId {
71 fn from(s: &str) -> Self {
72 Self::from_name(s)
73 }
74}
75
76impl From<String> for AgentId {
77 fn from(s: String) -> Self {
78 Self::from_name(&s)
79 }
80}
81
82impl FromStr for AgentId {
83 type Err = std::convert::Infallible;
84
85 fn from_str(s: &str) -> Result<Self, Self::Err> {
86 Ok(Self::from_name(s))
87 }
88}
89
90#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
98pub enum Liveness {
99 #[default]
102 Unknown,
103 Live,
105 Unreachable,
108}
109
110impl Liveness {
111 fn is_promising(self) -> bool {
114 !matches!(self, Liveness::Unreachable)
115 }
116}
117
118#[derive(Debug, Clone)]
125pub struct RegisteredAgent {
126 pub id: AgentId,
128 pub card: AgentCard,
130 pub endpoint: String,
132 pub liveness: Liveness,
134}
135
136#[derive(Debug, Error)]
138pub enum RegistryError {
139 #[error("no agent registered with id '{0}'")]
141 NotFound(AgentId),
142}
143
144#[async_trait]
148pub trait AgentRegistry: Send + Sync {
149 async fn register(&self, card: AgentCard, endpoint: String) -> Result<AgentId, RegistryError>;
155
156 async fn mark(&self, id: &AgentId, liveness: Liveness) -> Result<(), RegistryError>;
159
160 async fn deregister(&self, id: &AgentId) -> Result<(), RegistryError>;
163
164 async fn get(&self, id: &AgentId) -> Result<Option<RegisteredAgent>, RegistryError>;
166
167 async fn find_by_skill(&self, skill: &str) -> Result<Vec<RegisteredAgent>, RegistryError>;
176
177 async fn list(&self) -> Result<Vec<RegisteredAgent>, RegistryError>;
179}
180
181#[derive(Clone, Default)]
188pub struct InMemoryAgentRegistry {
189 agents: Arc<RwLock<HashMap<AgentId, RegisteredAgent>>>,
190}
191
192impl InMemoryAgentRegistry {
193 pub fn new() -> Self {
195 Self::default()
196 }
197}
198
199fn card_has_skill(card: &AgentCard, query: &str) -> bool {
202 card.skills.iter().any(|skill| {
203 skill.id.eq_ignore_ascii_case(query)
204 || skill.tags.iter().any(|tag| tag.eq_ignore_ascii_case(query))
205 })
206}
207
208#[async_trait]
209impl AgentRegistry for InMemoryAgentRegistry {
210 async fn register(&self, card: AgentCard, endpoint: String) -> Result<AgentId, RegistryError> {
211 let id = AgentId::from_name(&card.name);
212 let entry = RegisteredAgent {
213 id: id.clone(),
214 card,
215 endpoint,
216 liveness: Liveness::Unknown,
217 };
218 self.agents.write().await.insert(id.clone(), entry);
219 Ok(id)
220 }
221
222 async fn mark(&self, id: &AgentId, liveness: Liveness) -> Result<(), RegistryError> {
223 match self.agents.write().await.get_mut(id) {
224 Some(agent) => {
225 agent.liveness = liveness;
226 Ok(())
227 }
228 None => Err(RegistryError::NotFound(id.clone())),
229 }
230 }
231
232 async fn deregister(&self, id: &AgentId) -> Result<(), RegistryError> {
233 self.agents
234 .write()
235 .await
236 .remove(id)
237 .map(|_| ())
238 .ok_or_else(|| RegistryError::NotFound(id.clone()))
239 }
240
241 async fn get(&self, id: &AgentId) -> Result<Option<RegisteredAgent>, RegistryError> {
242 Ok(self.agents.read().await.get(id).cloned())
243 }
244
245 async fn find_by_skill(&self, skill: &str) -> Result<Vec<RegisteredAgent>, RegistryError> {
246 let mut matches: Vec<RegisteredAgent> = self
247 .agents
248 .read()
249 .await
250 .values()
251 .filter(|agent| card_has_skill(&agent.card, skill))
252 .cloned()
253 .collect();
254 matches.sort_by_key(|agent| !agent.liveness.is_promising());
257 Ok(matches)
258 }
259
260 async fn list(&self) -> Result<Vec<RegisteredAgent>, RegistryError> {
261 Ok(self.agents.read().await.values().cloned().collect())
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use a2a_rs::domain::AgentSkill;
269
270 fn card_with_skills(name: &str, skills: Vec<AgentSkill>) -> AgentCard {
271 let mut card = AgentCard {
272 name: name.to_string(),
273 ..Default::default()
274 };
275 card.skills = skills;
276 card
277 }
278
279 #[test]
280 fn agent_id_is_slugified_from_name() {
281 assert_eq!(
282 AgentId::from_name("Weather Agent").as_str(),
283 "weather-agent"
284 );
285 assert_eq!(AgentId::from_name("billing").as_str(), "billing");
286 }
287
288 #[test]
289 fn agent_id_conversions_canonicalize_lookup_keys() {
290 let canonical = AgentId::from_name("Weather Agent");
294 assert_eq!(AgentId::from("Weather Agent"), canonical);
295 assert_eq!(AgentId::from("Weather Agent".to_string()), canonical);
296 assert_eq!("Weather Agent".parse::<AgentId>().unwrap(), canonical);
297 assert_eq!(AgentId::from("weather-agent"), canonical);
299 }
300
301 #[tokio::test]
302 async fn get_resolves_non_canonical_lookup_key() {
303 let reg = InMemoryAgentRegistry::new();
304 reg.register(card_with_skills("Weather Agent", vec![]), "http://w".into())
305 .await
306 .unwrap();
307
308 let got = reg.get(&AgentId::from("Weather Agent")).await.unwrap();
311 assert!(got.is_some(), "non-canonical key should resolve");
312 assert_eq!(got.unwrap().card.name, "Weather Agent");
313 }
314
315 #[tokio::test]
316 async fn register_then_get_round_trips() {
317 let reg = InMemoryAgentRegistry::new();
318 let card = card_with_skills("Weather Agent", vec![]);
319 let id = reg
320 .register(card, "http://127.0.0.1:9000".into())
321 .await
322 .unwrap();
323 assert_eq!(id.as_str(), "weather-agent");
324
325 let got = reg.get(&id).await.unwrap().expect("registered agent");
326 assert_eq!(got.endpoint, "http://127.0.0.1:9000");
327 assert_eq!(got.card.name, "Weather Agent");
328
329 assert!(reg.get(&AgentId::from("missing")).await.unwrap().is_none());
330 }
331
332 #[tokio::test]
333 async fn find_by_skill_matches_id_and_tag_case_insensitively() {
334 let reg = InMemoryAgentRegistry::new();
335 let skill = AgentSkill::new(
336 "weather-lookup".into(),
337 "Weather lookup".into(),
338 "Looks up the weather".into(),
339 vec!["forecast".into(), "meteorology".into()],
340 );
341 reg.register(
342 card_with_skills("Weather Agent", vec![skill]),
343 "http://w".into(),
344 )
345 .await
346 .unwrap();
347 reg.register(card_with_skills("Idle Agent", vec![]), "http://i".into())
348 .await
349 .unwrap();
350
351 let by_id = reg.find_by_skill("Weather-Lookup").await.unwrap();
353 assert_eq!(by_id.len(), 1);
354 assert_eq!(by_id[0].card.name, "Weather Agent");
355
356 let by_tag = reg.find_by_skill("FORECAST").await.unwrap();
358 assert_eq!(by_tag.len(), 1);
359
360 assert!(reg.find_by_skill("billing").await.unwrap().is_empty());
362 }
363
364 #[tokio::test]
365 async fn find_by_skill_returns_all_matches() {
366 let reg = InMemoryAgentRegistry::new();
367 let mk =
368 |id: &str| AgentSkill::new(id.into(), "S".into(), "d".into(), vec!["shared".into()]);
369 reg.register(card_with_skills("A", vec![mk("a")]), "http://a".into())
370 .await
371 .unwrap();
372 reg.register(card_with_skills("B", vec![mk("b")]), "http://b".into())
373 .await
374 .unwrap();
375
376 let matches = reg.find_by_skill("shared").await.unwrap();
377 assert_eq!(matches.len(), 2);
378 }
379
380 #[tokio::test]
381 async fn register_upserts_by_name() {
382 let reg = InMemoryAgentRegistry::new();
383 reg.register(card_with_skills("Agent", vec![]), "http://old".into())
384 .await
385 .unwrap();
386 reg.register(card_with_skills("Agent", vec![]), "http://new".into())
387 .await
388 .unwrap();
389
390 assert_eq!(reg.list().await.unwrap().len(), 1);
391 let got = reg
392 .get(&AgentId::from_name("Agent"))
393 .await
394 .unwrap()
395 .unwrap();
396 assert_eq!(got.endpoint, "http://new");
397 }
398
399 #[tokio::test]
400 async fn deregister_removes_then_errors() {
401 let reg = InMemoryAgentRegistry::new();
402 let id = reg
403 .register(card_with_skills("Agent", vec![]), "http://a".into())
404 .await
405 .unwrap();
406
407 assert!(reg.deregister(&id).await.is_ok());
408 assert!(reg.list().await.unwrap().is_empty());
409
410 match reg.deregister(&id).await {
411 Err(RegistryError::NotFound(missing)) => assert_eq!(missing, id),
412 other => panic!("expected NotFound, got {other:?}"),
413 }
414 }
415}