a2a-agents 0.7.0

Framework for building A2A Protocol agents with declarative configuration
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
//! Agent registry / discovery.
//!
//! The [`AgentRegistry`] port is a **platform capability**: it stores the
//! [`AgentCard`]s of known agents alongside a dialable endpoint, so an
//! orchestrator can find peers by **skill** instead of a hard-coded URL. This is
//! the discovery half of the multi-agent platform — the delegation half is
//! [`A2aAgentToolSource`](crate::A2aAgentToolSource).
//!
//! Per the hexagonal rules this lives in the platform layer (never in the pure
//! `a2a-rs` protocol crate): a capability port plus an in-memory adapter. A
//! future sqlx- or control-plane-backed adapter is a drop-in behind the same
//! port — which is why every method returns [`Result`] even though
//! [`InMemoryAgentRegistry`] never actually fails.
//!
//! [`CardSource`] is the neighbouring capability: this module's registry
//! *stores* cards, while a `CardSource` *obtains* one from a running agent —
//! which is how a restarted control plane rebuilds discovery from the agents it
//! finds still running.

mod card_source;
mod peer;
mod refresh;

pub use card_source::{CardSource, CardSourceError, HttpCardSource, InMemoryCardSource};
pub use peer::DiscoveredPeer;
pub use refresh::{CardRefresher, DEFAULT_REFRESH_INTERVAL, RefreshReport};

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;

/// A stable, user-predictable identifier for a registered agent.
///
/// Derived by slugifying the agent's name (lowercase, non-alphanumeric → `-`),
/// so a config can reference a peer with `agent_id = "weather-agent"` and have
/// it resolve deterministically.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AgentId(String);

impl AgentId {
    /// Derive an id from a free-form agent name.
    pub fn from_name(name: &str) -> Self {
        Self(crate::utils::slugify(name, '-'))
    }

    /// The id as a string slice.
    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)
    }
}

// All conversions route through `from_name` so a raw lookup key (an HTTP path
// param, a config `agent_id` ref) canonicalizes to the same slug the stored id
// was built from. `slugify` is idempotent, so converting an already-canonical
// slug is a no-op; without this, `agent_id = "Weather Agent"` would silently
// miss a registry whose key is `weather-agent`.
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))
    }
}

/// What the registry last observed about a registered agent.
///
/// Three states rather than a `reachable` flag, because "we looked and it
/// answered" and "we have not looked" are different facts and only the first is
/// evidence — the same split as [`Recovered`](crate::Recovered). Collapsing them
/// would make a freshly registered agent indistinguishable from a dead one, or
/// a dead one from an unprobed one, depending which way the default fell.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Liveness {
    /// Registered, never probed. What [`AgentRegistry::register`] leaves behind:
    /// a config-derived card says nothing about whether the agent is up.
    #[default]
    Unknown,
    /// Its card was readable the last time we looked.
    Live,
    /// The last card fetch failed. Still registered — the entry is the record
    /// that this agent exists — but ranked behind anything that answered.
    Unreachable,
}

impl Liveness {
    /// Whether this agent should be preferred when several provide a skill.
    /// Unprobed counts: not having looked is not evidence against it.
    fn is_promising(self) -> bool {
        !matches!(self, Liveness::Unreachable)
    }
}

/// An agent known to the registry: its discovery metadata ([`AgentCard`]) plus
/// the endpoint to dial.
///
/// `endpoint` is kept separate from `card.url` on purpose: the externally
/// reachable address can differ from what the card advertises (NAT, container
/// networking), so the registry dials `endpoint` and discovers against `card`.
#[derive(Debug, Clone)]
pub struct RegisteredAgent {
    /// Registry-assigned id (derived from the card's name).
    pub id: AgentId,
    /// The agent's published card — name, description, skills, capabilities.
    pub card: AgentCard,
    /// Dialable base URL for the agent's A2A endpoint.
    pub endpoint: String,
    /// What the last liveness probe found. See [`CardRefresher`].
    pub liveness: Liveness,
}

/// Errors a registry operation can return.
#[derive(Debug, Error)]
pub enum RegistryError {
    /// No agent is registered under the given id.
    #[error("no agent registered with id '{0}'")]
    NotFound(AgentId),
}

/// The discovery capability the platform needs: register agents and find them
/// by id or skill. One trait per capability (hex rule 2); implemented by
/// [`InMemoryAgentRegistry`] today, a persistent adapter later.
#[async_trait]
pub trait AgentRegistry: Send + Sync {
    /// Register (or replace) an agent. The id is derived from the card's name;
    /// re-registering the same name upserts (last-writer-wins).
    ///
    /// Liveness resets to [`Liveness::Unknown`]: a re-registration is a new
    /// claim about where the agent is, and what was probed was the old one.
    async fn register(&self, card: AgentCard, endpoint: String) -> Result<AgentId, RegistryError>;

    /// Record what a liveness probe found. Returns [`RegistryError::NotFound`]
    /// if the agent was deregistered while the probe was in flight.
    async fn mark(&self, id: &AgentId, liveness: Liveness) -> Result<(), RegistryError>;

    /// Remove an agent. Returns [`RegistryError::NotFound`] if it was not
    /// registered.
    async fn deregister(&self, id: &AgentId) -> Result<(), RegistryError>;

    /// Look up an agent by id; `Ok(None)` when absent.
    async fn get(&self, id: &AgentId) -> Result<Option<RegisteredAgent>, RegistryError>;

    /// Find every agent whose card advertises a matching skill. A skill matches
    /// when its `id` or any of its `tags` equals `skill`, case-insensitively.
    ///
    /// Agents last seen [`Unreachable`](Liveness::Unreachable) are ordered
    /// last, not dropped: a caller taking the first match then gets a live peer
    /// when one exists, and when none does it still gets an entry — so the
    /// failure it reports is "could not connect to X" rather than the untrue
    /// "nobody provides this skill".
    async fn find_by_skill(&self, skill: &str) -> Result<Vec<RegisteredAgent>, RegistryError>;

    /// List every registered agent.
    async fn list(&self) -> Result<Vec<RegisteredAgent>, RegistryError>;
}

/// In-memory [`AgentRegistry`] adapter — the default, infra-free implementation.
///
/// A first-class type (not test-only, per hex rule 6): services and tests run
/// against it without standing up external infrastructure. Cheap to `clone`
/// (shares one map); reads (`find_by_skill`, `get`) dominate writes, hence the
/// [`RwLock`].
#[derive(Clone, Default)]
pub struct InMemoryAgentRegistry {
    agents: Arc<RwLock<HashMap<AgentId, RegisteredAgent>>>,
}

impl InMemoryAgentRegistry {
    /// Create an empty registry.
    pub fn new() -> Self {
        Self::default()
    }
}

/// True if `card` advertises a skill matching `query` by id or tag,
/// case-insensitively.
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,
            liveness: Liveness::Unknown,
        };
        self.agents.write().await.insert(id.clone(), entry);
        Ok(id)
    }

    async fn mark(&self, id: &AgentId, liveness: Liveness) -> Result<(), RegistryError> {
        match self.agents.write().await.get_mut(id) {
            Some(agent) => {
                agent.liveness = liveness;
                Ok(())
            }
            None => Err(RegistryError::NotFound(id.clone())),
        }
    }

    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> {
        let mut matches: Vec<RegisteredAgent> = self
            .agents
            .read()
            .await
            .values()
            .filter(|agent| card_has_skill(&agent.card, skill))
            .cloned()
            .collect();
        // A stable sort on one boolean key: it moves the unreachable to the
        // back and leaves everything else where it was.
        matches.sort_by_key(|agent| !agent.liveness.is_promising());
        Ok(matches)
    }

    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() {
        // A raw lookup key (HTTP path param, config `agent_id` ref) must
        // canonicalize to the same slug `from_name` produced, or lookups
        // silently miss.
        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);
        // already-canonical key is a no-op
        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();

        // Looking up by the raw, non-canonical name resolves to the slugified
        // entry rather than silently missing.
        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();

        // by skill id (case-insensitive)
        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");

        // by tag (case-insensitive)
        let by_tag = reg.find_by_skill("FORECAST").await.unwrap();
        assert_eq!(by_tag.len(), 1);

        // no match
        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:?}"),
        }
    }
}