Skip to main content

a2a_agents/registry/
card_source.rs

1//! Reading an agent's published card from its endpoint.
2//!
3//! The [`AgentRegistry`](super::AgentRegistry) *stores* cards; this port
4//! *obtains* one from a running agent. They are separate capabilities because
5//! the registry is a store the platform owns, while a card comes off the wire
6//! from something the platform merely supervises.
7//!
8//! The reason it is a port at all: recovering a control plane means putting
9//! already-running agents back into discovery, which requires asking each one
10//! for its card. Without this seam that orchestration could only be tested with
11//! real HTTP servers, and the service layer would be reaching for an adapter
12//! (dependency rule).
13
14use std::collections::HashMap;
15use std::sync::Arc;
16
17use async_trait::async_trait;
18use thiserror::Error;
19use tokio::sync::RwLock;
20
21use a2a_rs::domain::AgentCard;
22
23/// Why a card could not be read.
24///
25/// One variant on purpose: from the caller's side every failure means the same
26/// thing — the agent did not hand over a card — and the underlying reason is
27/// diagnostic text, not something to branch on.
28#[derive(Debug, Error)]
29#[error("could not read the agent card at {endpoint}: {reason}")]
30pub struct CardSourceError {
31    /// The endpoint that was asked.
32    pub endpoint: String,
33    /// What went wrong, as reported by the transport.
34    pub reason: String,
35}
36
37/// Obtain a running agent's published [`AgentCard`] from its endpoint.
38#[async_trait]
39pub trait CardSource: Send + Sync {
40    /// Fetch the card served at `endpoint` (a dialable base URL).
41    async fn fetch(&self, endpoint: &str) -> Result<AgentCard, CardSourceError>;
42}
43
44/// The real adapter: fetches `<endpoint>/.well-known/agent-card.json`.
45#[derive(Debug, Clone, Copy, Default)]
46pub struct HttpCardSource;
47
48impl HttpCardSource {
49    /// Create the adapter (it holds no state).
50    pub fn new() -> Self {
51        Self
52    }
53}
54
55#[async_trait]
56impl CardSource for HttpCardSource {
57    async fn fetch(&self, endpoint: &str) -> Result<AgentCard, CardSourceError> {
58        a2a_rs::fetch_agent_card(endpoint)
59            .await
60            .map_err(|e| CardSourceError {
61                endpoint: endpoint.to_string(),
62                reason: e.to_string(),
63            })
64    }
65}
66
67/// An infra-free [`CardSource`] serving cards from a map.
68///
69/// A first-class type (hex rule 6): it lets recovery — which has to re-register
70/// what it adopts, tolerate agents that do not answer, and stay idempotent — be
71/// tested without standing up HTTP servers.
72#[derive(Clone, Default)]
73pub struct InMemoryCardSource {
74    cards: Arc<RwLock<HashMap<String, AgentCard>>>,
75}
76
77impl InMemoryCardSource {
78    /// Create an empty source. Every endpoint is unreachable until inserted.
79    pub fn new() -> Self {
80        Self::default()
81    }
82
83    /// Make `card` the answer for `endpoint`.
84    pub async fn insert(&self, endpoint: impl Into<String>, card: AgentCard) {
85        self.cards.write().await.insert(endpoint.into(), card);
86    }
87}
88
89#[async_trait]
90impl CardSource for InMemoryCardSource {
91    async fn fetch(&self, endpoint: &str) -> Result<AgentCard, CardSourceError> {
92        self.cards
93            .read()
94            .await
95            .get(endpoint)
96            .cloned()
97            .ok_or_else(|| CardSourceError {
98                endpoint: endpoint.to_string(),
99                reason: "no card registered for this endpoint".to_string(),
100            })
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[tokio::test]
109    async fn in_memory_source_answers_only_for_known_endpoints() {
110        let source = InMemoryCardSource::new();
111        let card = AgentCard {
112            name: "Weather Agent".to_string(),
113            ..Default::default()
114        };
115        source.insert("http://127.0.0.1:8080", card).await;
116
117        assert_eq!(
118            source.fetch("http://127.0.0.1:8080").await.unwrap().name,
119            "Weather Agent"
120        );
121
122        // An unknown endpoint fails the way an unreachable agent does, and the
123        // error names it — recovery logs this per agent.
124        let err = source.fetch("http://127.0.0.1:9999").await.unwrap_err();
125        assert_eq!(err.endpoint, "http://127.0.0.1:9999");
126        assert!(err.to_string().contains("http://127.0.0.1:9999"));
127    }
128}