Skip to main content

a2a_agents/registry/
mod.rs

1//! Agent registry / discovery.
2//!
3//! The [`AgentRegistry`] port is a **platform capability**: it stores the
4//! [`AgentCard`]s of known agents alongside a dialable endpoint, so an
5//! orchestrator can find peers by **skill** instead of a hard-coded URL. This is
6//! the discovery half of the multi-agent platform — the delegation half is
7//! [`A2aAgentToolSource`](crate::A2aAgentToolSource).
8//!
9//! Per the hexagonal rules this lives in the platform layer (never in the pure
10//! `a2a-rs` protocol crate): a capability port plus an in-memory adapter. A
11//! future sqlx- or control-plane-backed adapter is a drop-in behind the same
12//! port — which is why every method returns [`Result`] even though
13//! [`InMemoryAgentRegistry`] never actually fails.
14//!
15//! [`CardSource`] is the neighbouring capability: this module's registry
16//! *stores* cards, while a `CardSource` *obtains* one from a running agent —
17//! which is how a restarted control plane rebuilds discovery from the agents it
18//! finds still running.
19
20mod 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/// A stable, user-predictable identifier for a registered agent.
40///
41/// Derived by slugifying the agent's name (lowercase, non-alphanumeric → `-`),
42/// so a config can reference a peer with `agent_id = "weather-agent"` and have
43/// it resolve deterministically.
44#[derive(Debug, Clone, PartialEq, Eq, Hash)]
45pub struct AgentId(String);
46
47impl AgentId {
48    /// Derive an id from a free-form agent name.
49    pub fn from_name(name: &str) -> Self {
50        Self(crate::utils::slugify(name, '-'))
51    }
52
53    /// The id as a string slice.
54    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
65// All conversions route through `from_name` so a raw lookup key (an HTTP path
66// param, a config `agent_id` ref) canonicalizes to the same slug the stored id
67// was built from. `slugify` is idempotent, so converting an already-canonical
68// slug is a no-op; without this, `agent_id = "Weather Agent"` would silently
69// miss a registry whose key is `weather-agent`.
70impl 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/// What the registry last observed about a registered agent.
91///
92/// Three states rather than a `reachable` flag, because "we looked and it
93/// answered" and "we have not looked" are different facts and only the first is
94/// evidence — the same split as [`Recovered`](crate::Recovered). Collapsing them
95/// would make a freshly registered agent indistinguishable from a dead one, or
96/// a dead one from an unprobed one, depending which way the default fell.
97#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
98pub enum Liveness {
99    /// Registered, never probed. What [`AgentRegistry::register`] leaves behind:
100    /// a config-derived card says nothing about whether the agent is up.
101    #[default]
102    Unknown,
103    /// Its card was readable the last time we looked.
104    Live,
105    /// The last card fetch failed. Still registered — the entry is the record
106    /// that this agent exists — but ranked behind anything that answered.
107    Unreachable,
108}
109
110impl Liveness {
111    /// Whether this agent should be preferred when several provide a skill.
112    /// Unprobed counts: not having looked is not evidence against it.
113    fn is_promising(self) -> bool {
114        !matches!(self, Liveness::Unreachable)
115    }
116}
117
118/// An agent known to the registry: its discovery metadata ([`AgentCard`]) plus
119/// the endpoint to dial.
120///
121/// `endpoint` is kept separate from `card.url` on purpose: the externally
122/// reachable address can differ from what the card advertises (NAT, container
123/// networking), so the registry dials `endpoint` and discovers against `card`.
124#[derive(Debug, Clone)]
125pub struct RegisteredAgent {
126    /// Registry-assigned id (derived from the card's name).
127    pub id: AgentId,
128    /// The agent's published card — name, description, skills, capabilities.
129    pub card: AgentCard,
130    /// Dialable base URL for the agent's A2A endpoint.
131    pub endpoint: String,
132    /// What the last liveness probe found. See [`CardRefresher`].
133    pub liveness: Liveness,
134}
135
136/// Errors a registry operation can return.
137#[derive(Debug, Error)]
138pub enum RegistryError {
139    /// No agent is registered under the given id.
140    #[error("no agent registered with id '{0}'")]
141    NotFound(AgentId),
142}
143
144/// The discovery capability the platform needs: register agents and find them
145/// by id or skill. One trait per capability (hex rule 2); implemented by
146/// [`InMemoryAgentRegistry`] today, a persistent adapter later.
147#[async_trait]
148pub trait AgentRegistry: Send + Sync {
149    /// Register (or replace) an agent. The id is derived from the card's name;
150    /// re-registering the same name upserts (last-writer-wins).
151    ///
152    /// Liveness resets to [`Liveness::Unknown`]: a re-registration is a new
153    /// claim about where the agent is, and what was probed was the old one.
154    async fn register(&self, card: AgentCard, endpoint: String) -> Result<AgentId, RegistryError>;
155
156    /// Record what a liveness probe found. Returns [`RegistryError::NotFound`]
157    /// if the agent was deregistered while the probe was in flight.
158    async fn mark(&self, id: &AgentId, liveness: Liveness) -> Result<(), RegistryError>;
159
160    /// Remove an agent. Returns [`RegistryError::NotFound`] if it was not
161    /// registered.
162    async fn deregister(&self, id: &AgentId) -> Result<(), RegistryError>;
163
164    /// Look up an agent by id; `Ok(None)` when absent.
165    async fn get(&self, id: &AgentId) -> Result<Option<RegisteredAgent>, RegistryError>;
166
167    /// Find every agent whose card advertises a matching skill. A skill matches
168    /// when its `id` or any of its `tags` equals `skill`, case-insensitively.
169    ///
170    /// Agents last seen [`Unreachable`](Liveness::Unreachable) are ordered
171    /// last, not dropped: a caller taking the first match then gets a live peer
172    /// when one exists, and when none does it still gets an entry — so the
173    /// failure it reports is "could not connect to X" rather than the untrue
174    /// "nobody provides this skill".
175    async fn find_by_skill(&self, skill: &str) -> Result<Vec<RegisteredAgent>, RegistryError>;
176
177    /// List every registered agent.
178    async fn list(&self) -> Result<Vec<RegisteredAgent>, RegistryError>;
179}
180
181/// In-memory [`AgentRegistry`] adapter — the default, infra-free implementation.
182///
183/// A first-class type (not test-only, per hex rule 6): services and tests run
184/// against it without standing up external infrastructure. Cheap to `clone`
185/// (shares one map); reads (`find_by_skill`, `get`) dominate writes, hence the
186/// [`RwLock`].
187#[derive(Clone, Default)]
188pub struct InMemoryAgentRegistry {
189    agents: Arc<RwLock<HashMap<AgentId, RegisteredAgent>>>,
190}
191
192impl InMemoryAgentRegistry {
193    /// Create an empty registry.
194    pub fn new() -> Self {
195        Self::default()
196    }
197}
198
199/// True if `card` advertises a skill matching `query` by id or tag,
200/// case-insensitively.
201fn 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        // A stable sort on one boolean key: it moves the unreachable to the
255        // back and leaves everything else where it was.
256        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        // A raw lookup key (HTTP path param, config `agent_id` ref) must
291        // canonicalize to the same slug `from_name` produced, or lookups
292        // silently miss.
293        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        // already-canonical key is a no-op
298        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        // Looking up by the raw, non-canonical name resolves to the slugified
309        // entry rather than silently missing.
310        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        // by skill id (case-insensitive)
352        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        // by tag (case-insensitive)
357        let by_tag = reg.find_by_skill("FORECAST").await.unwrap();
358        assert_eq!(by_tag.len(), 1);
359
360        // no match
361        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}