use std::collections::HashMap;
use std::fmt;
use std::str::FromStr;
use std::sync::Arc;
use async_trait::async_trait;
use thiserror::Error;
use tokio::sync::RwLock;
use a2a_rs::domain::AgentCard;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AgentId(String);
impl AgentId {
pub fn from_name(name: &str) -> Self {
Self(crate::utils::slugify(name, '-'))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for AgentId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl From<&str> for AgentId {
fn from(s: &str) -> Self {
Self::from_name(s)
}
}
impl From<String> for AgentId {
fn from(s: String) -> Self {
Self::from_name(&s)
}
}
impl FromStr for AgentId {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self::from_name(s))
}
}
#[derive(Debug, Clone)]
pub struct RegisteredAgent {
pub id: AgentId,
pub card: AgentCard,
pub endpoint: String,
}
#[derive(Debug, Error)]
pub enum RegistryError {
#[error("no agent registered with id '{0}'")]
NotFound(AgentId),
}
#[async_trait]
pub trait AgentRegistry: Send + Sync {
async fn register(&self, card: AgentCard, endpoint: String) -> Result<AgentId, RegistryError>;
async fn deregister(&self, id: &AgentId) -> Result<(), RegistryError>;
async fn get(&self, id: &AgentId) -> Result<Option<RegisteredAgent>, RegistryError>;
async fn find_by_skill(&self, skill: &str) -> Result<Vec<RegisteredAgent>, RegistryError>;
async fn list(&self) -> Result<Vec<RegisteredAgent>, RegistryError>;
}
#[derive(Clone, Default)]
pub struct InMemoryAgentRegistry {
agents: Arc<RwLock<HashMap<AgentId, RegisteredAgent>>>,
}
impl InMemoryAgentRegistry {
pub fn new() -> Self {
Self::default()
}
}
fn card_has_skill(card: &AgentCard, query: &str) -> bool {
card.skills.iter().any(|skill| {
skill.id.eq_ignore_ascii_case(query)
|| skill.tags.iter().any(|tag| tag.eq_ignore_ascii_case(query))
})
}
#[async_trait]
impl AgentRegistry for InMemoryAgentRegistry {
async fn register(&self, card: AgentCard, endpoint: String) -> Result<AgentId, RegistryError> {
let id = AgentId::from_name(&card.name);
let entry = RegisteredAgent {
id: id.clone(),
card,
endpoint,
};
self.agents.write().await.insert(id.clone(), entry);
Ok(id)
}
async fn deregister(&self, id: &AgentId) -> Result<(), RegistryError> {
self.agents
.write()
.await
.remove(id)
.map(|_| ())
.ok_or_else(|| RegistryError::NotFound(id.clone()))
}
async fn get(&self, id: &AgentId) -> Result<Option<RegisteredAgent>, RegistryError> {
Ok(self.agents.read().await.get(id).cloned())
}
async fn find_by_skill(&self, skill: &str) -> Result<Vec<RegisteredAgent>, RegistryError> {
Ok(self
.agents
.read()
.await
.values()
.filter(|agent| card_has_skill(&agent.card, skill))
.cloned()
.collect())
}
async fn list(&self) -> Result<Vec<RegisteredAgent>, RegistryError> {
Ok(self.agents.read().await.values().cloned().collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
use a2a_rs::domain::AgentSkill;
fn card_with_skills(name: &str, skills: Vec<AgentSkill>) -> AgentCard {
let mut card = AgentCard {
name: name.to_string(),
..Default::default()
};
card.skills = skills;
card
}
#[test]
fn agent_id_is_slugified_from_name() {
assert_eq!(
AgentId::from_name("Weather Agent").as_str(),
"weather-agent"
);
assert_eq!(AgentId::from_name("billing").as_str(), "billing");
}
#[test]
fn agent_id_conversions_canonicalize_lookup_keys() {
let canonical = AgentId::from_name("Weather Agent");
assert_eq!(AgentId::from("Weather Agent"), canonical);
assert_eq!(AgentId::from("Weather Agent".to_string()), canonical);
assert_eq!("Weather Agent".parse::<AgentId>().unwrap(), canonical);
assert_eq!(AgentId::from("weather-agent"), canonical);
}
#[tokio::test]
async fn get_resolves_non_canonical_lookup_key() {
let reg = InMemoryAgentRegistry::new();
reg.register(card_with_skills("Weather Agent", vec![]), "http://w".into())
.await
.unwrap();
let got = reg.get(&AgentId::from("Weather Agent")).await.unwrap();
assert!(got.is_some(), "non-canonical key should resolve");
assert_eq!(got.unwrap().card.name, "Weather Agent");
}
#[tokio::test]
async fn register_then_get_round_trips() {
let reg = InMemoryAgentRegistry::new();
let card = card_with_skills("Weather Agent", vec![]);
let id = reg
.register(card, "http://127.0.0.1:9000".into())
.await
.unwrap();
assert_eq!(id.as_str(), "weather-agent");
let got = reg.get(&id).await.unwrap().expect("registered agent");
assert_eq!(got.endpoint, "http://127.0.0.1:9000");
assert_eq!(got.card.name, "Weather Agent");
assert!(reg.get(&AgentId::from("missing")).await.unwrap().is_none());
}
#[tokio::test]
async fn find_by_skill_matches_id_and_tag_case_insensitively() {
let reg = InMemoryAgentRegistry::new();
let skill = AgentSkill::new(
"weather-lookup".into(),
"Weather lookup".into(),
"Looks up the weather".into(),
vec!["forecast".into(), "meteorology".into()],
);
reg.register(
card_with_skills("Weather Agent", vec![skill]),
"http://w".into(),
)
.await
.unwrap();
reg.register(card_with_skills("Idle Agent", vec![]), "http://i".into())
.await
.unwrap();
let by_id = reg.find_by_skill("Weather-Lookup").await.unwrap();
assert_eq!(by_id.len(), 1);
assert_eq!(by_id[0].card.name, "Weather Agent");
let by_tag = reg.find_by_skill("FORECAST").await.unwrap();
assert_eq!(by_tag.len(), 1);
assert!(reg.find_by_skill("billing").await.unwrap().is_empty());
}
#[tokio::test]
async fn find_by_skill_returns_all_matches() {
let reg = InMemoryAgentRegistry::new();
let mk =
|id: &str| AgentSkill::new(id.into(), "S".into(), "d".into(), vec!["shared".into()]);
reg.register(card_with_skills("A", vec![mk("a")]), "http://a".into())
.await
.unwrap();
reg.register(card_with_skills("B", vec![mk("b")]), "http://b".into())
.await
.unwrap();
let matches = reg.find_by_skill("shared").await.unwrap();
assert_eq!(matches.len(), 2);
}
#[tokio::test]
async fn register_upserts_by_name() {
let reg = InMemoryAgentRegistry::new();
reg.register(card_with_skills("Agent", vec![]), "http://old".into())
.await
.unwrap();
reg.register(card_with_skills("Agent", vec![]), "http://new".into())
.await
.unwrap();
assert_eq!(reg.list().await.unwrap().len(), 1);
let got = reg
.get(&AgentId::from_name("Agent"))
.await
.unwrap()
.unwrap();
assert_eq!(got.endpoint, "http://new");
}
#[tokio::test]
async fn deregister_removes_then_errors() {
let reg = InMemoryAgentRegistry::new();
let id = reg
.register(card_with_skills("Agent", vec![]), "http://a".into())
.await
.unwrap();
assert!(reg.deregister(&id).await.is_ok());
assert!(reg.list().await.unwrap().is_empty());
match reg.deregister(&id).await {
Err(RegistryError::NotFound(missing)) => assert_eq!(missing, id),
other => panic!("expected NotFound, got {other:?}"),
}
}
}