Skip to main content

a2a_agents/registry/
refresh.rs

1//! Keeping the registry's picture of who is up from going stale.
2//!
3//! Registry entries are written once — at `a2a run` startup, or by
4//! `ControlPlane::deploy`/`recover` — and were never revisited, so an agent that
5//! died stayed discoverable and kept being handed work. [`CardRefresher`] probes
6//! each registered agent on an interval and records what it found.
7//!
8//! The probe is a card fetch because that is the one request every A2A agent
9//! must answer, and it goes through the [`CardSource`] port so the loop is
10//! testable without HTTP servers.
11//!
12//! It reads the card and does **not** adopt it. Re-registering would derive the
13//! id from the fetched card's `name`, so an agent that renamed itself would land
14//! under a second id with the old entry left behind — a duplicate that the next
15//! skill lookup would happily hand work to. Refreshing card *content* needs an
16//! update-in-place that `register` is not; until then this loop answers one
17//! question, which is whether the agent is there.
18
19use std::sync::Arc;
20use std::time::Duration;
21
22use tracing::{debug, info, warn};
23
24use super::{AgentRegistry, Liveness, RegistryError};
25use crate::registry::CardSource;
26
27/// How often to probe, when the caller does not say. Long enough not to be
28/// traffic, short enough that a dead peer stops being delegated to within a
29/// minute or so.
30pub const DEFAULT_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
31
32/// What one pass over the registry found. Returned so a caller can log or test
33/// a pass without inspecting the registry afterwards.
34#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
35pub struct RefreshReport {
36    /// Agents whose card was readable.
37    pub live: usize,
38    /// Agents that did not answer.
39    pub unreachable: usize,
40}
41
42impl RefreshReport {
43    /// How many agents were probed.
44    pub fn probed(&self) -> usize {
45        self.live + self.unreachable
46    }
47}
48
49/// Probes registered agents and records whether they answered.
50pub struct CardRefresher {
51    registry: Arc<dyn AgentRegistry>,
52    cards: Arc<dyn CardSource>,
53    interval: Duration,
54}
55
56impl CardRefresher {
57    /// Build a refresher over a registry and a way to read cards.
58    pub fn new(registry: Arc<dyn AgentRegistry>, cards: Arc<dyn CardSource>) -> Self {
59        Self {
60            registry,
61            cards,
62            interval: DEFAULT_REFRESH_INTERVAL,
63        }
64    }
65
66    /// Probe on a different interval than [`DEFAULT_REFRESH_INTERVAL`].
67    pub fn every(mut self, interval: Duration) -> Self {
68        self.interval = interval;
69        self
70    }
71
72    /// Probe every registered agent once.
73    ///
74    /// Errors are per agent and never abort the pass: one unreachable agent
75    /// must not stop the others from being checked, which is the whole reason
76    /// to check them.
77    pub async fn refresh_once(&self) -> Result<RefreshReport, RegistryError> {
78        let mut report = RefreshReport::default();
79        for agent in self.registry.list().await? {
80            let liveness = match self.cards.fetch(&agent.endpoint).await {
81                Ok(_) => {
82                    report.live += 1;
83                    Liveness::Live
84                }
85                Err(e) => {
86                    report.unreachable += 1;
87                    // Not a warning on its own: an agent being down is what
88                    // this loop exists to notice, and warning every interval
89                    // for the same dead agent is noise.
90                    debug!(agent = %agent.id, error = %e, "liveness probe failed");
91                    Liveness::Unreachable
92                }
93            };
94
95            // Only report a *change*, so a steady state is silent and an agent
96            // dying or coming back is one line.
97            if agent.liveness != liveness {
98                match liveness {
99                    Liveness::Unreachable => warn!(
100                        agent = %agent.id,
101                        endpoint = %agent.endpoint,
102                        "agent stopped answering; ranked behind live peers for skill lookups"
103                    ),
104                    _ => info!(agent = %agent.id, endpoint = %agent.endpoint, "agent is answering"),
105                }
106            }
107
108            // Deregistered mid-pass: nothing to record, and not an error.
109            if let Err(RegistryError::NotFound(id)) = self.registry.mark(&agent.id, liveness).await
110            {
111                debug!(agent = %id, "deregistered while its probe was in flight");
112            }
113        }
114        Ok(report)
115    }
116
117    /// Probe on the interval, forever. Intended to be `tokio::spawn`ed.
118    ///
119    /// A pass that cannot even list the registry is logged and retried on the
120    /// next tick rather than ending the loop: the alternative leaves liveness
121    /// frozen at whatever it last was, with nothing saying so.
122    pub async fn run(self) {
123        let mut ticker = tokio::time::interval(self.interval);
124        // `interval`'s first tick is immediate, and probing at t=0 is wrong
125        // wherever this is spawned: `a2a run` registers its agents from their
126        // configs a moment *before* starting them, so an immediate pass would
127        // mark the whole fleet unreachable and warn about it. Whoever just
128        // registered has the freshest information there is; `Unknown` until the
129        // first real pass is the honest state, and it is already the one that
130        // costs nothing.
131        ticker.tick().await;
132        loop {
133            ticker.tick().await;
134            match self.refresh_once().await {
135                Ok(report) if report.probed() > 0 => {
136                    debug!(
137                        live = report.live,
138                        unreachable = report.unreachable,
139                        "liveness pass"
140                    )
141                }
142                Ok(_) => {}
143                Err(e) => warn!(error = %e, "could not read the registry to probe it"),
144            }
145        }
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::registry::{AgentId, InMemoryAgentRegistry, InMemoryCardSource};
153    use a2a_rs::domain::{AgentCard, AgentSkill};
154
155    fn card(name: &str, skill: &str) -> AgentCard {
156        let mut card = AgentCard {
157            name: name.to_string(),
158            ..Default::default()
159        };
160        card.skills = vec![AgentSkill::new(
161            skill.to_string(),
162            "S".to_string(),
163            "d".to_string(),
164            vec![],
165        )];
166        card
167    }
168
169    async fn registered(
170        registry: &Arc<dyn AgentRegistry>,
171        name: &str,
172        skill: &str,
173        endpoint: &str,
174    ) -> AgentId {
175        registry
176            .register(card(name, skill), endpoint.to_string())
177            .await
178            .unwrap()
179    }
180
181    async fn liveness_of(registry: &Arc<dyn AgentRegistry>, id: &AgentId) -> Liveness {
182        registry
183            .get(id)
184            .await
185            .unwrap()
186            .expect("registered")
187            .liveness
188    }
189
190    #[tokio::test]
191    async fn a_pass_records_who_answered() {
192        let registry: Arc<dyn AgentRegistry> = Arc::new(InMemoryAgentRegistry::new());
193        let up = registered(&registry, "Up", "forecasting", "http://up").await;
194        let down = registered(&registry, "Down", "forecasting", "http://down").await;
195
196        let cards = InMemoryCardSource::new();
197        cards.insert("http://up", card("Up", "forecasting")).await;
198
199        let report = CardRefresher::new(registry.clone(), Arc::new(cards))
200            .refresh_once()
201            .await
202            .unwrap();
203
204        assert_eq!(
205            report,
206            RefreshReport {
207                live: 1,
208                unreachable: 1
209            }
210        );
211        assert_eq!(liveness_of(&registry, &up).await, Liveness::Live);
212        assert_eq!(liveness_of(&registry, &down).await, Liveness::Unreachable);
213    }
214
215    /// A dead agent stays registered. Its entry is the record that it exists,
216    /// and an operator asking "where did it go" needs it — hide, never discard.
217    #[tokio::test]
218    async fn an_unreachable_agent_is_not_deregistered() {
219        let registry: Arc<dyn AgentRegistry> = Arc::new(InMemoryAgentRegistry::new());
220        registered(&registry, "Down", "forecasting", "http://down").await;
221
222        CardRefresher::new(registry.clone(), Arc::new(InMemoryCardSource::new()))
223            .refresh_once()
224            .await
225            .unwrap();
226
227        assert_eq!(registry.list().await.unwrap().len(), 1);
228    }
229
230    /// What the liveness is *for*: a skill lookup hands work to the peer that
231    /// answered, while the dead one is still returned behind it — so an
232    /// orchestrator with no live option reports a connection failure rather
233    /// than "nobody provides this skill".
234    #[tokio::test]
235    async fn skill_lookups_prefer_the_peer_that_answered() {
236        let registry: Arc<dyn AgentRegistry> = Arc::new(InMemoryAgentRegistry::new());
237        // Registered first, so only the probe can move it behind the other.
238        registered(&registry, "Down", "forecasting", "http://down").await;
239        registered(&registry, "Up", "forecasting", "http://up").await;
240
241        let cards = InMemoryCardSource::new();
242        cards.insert("http://up", card("Up", "forecasting")).await;
243        CardRefresher::new(registry.clone(), Arc::new(cards))
244            .refresh_once()
245            .await
246            .unwrap();
247
248        let matches = registry.find_by_skill("forecasting").await.unwrap();
249        assert_eq!(matches.len(), 2, "the dead peer is ranked, not dropped");
250        assert_eq!(matches[0].endpoint, "http://up");
251    }
252
253    /// An agent that comes back is delegated to again. A one-way transition
254    /// would need a restart to clear, which is worse than not probing at all.
255    #[tokio::test]
256    async fn an_agent_that_comes_back_is_marked_live_again() {
257        let registry: Arc<dyn AgentRegistry> = Arc::new(InMemoryAgentRegistry::new());
258        let id = registered(&registry, "Flaky", "forecasting", "http://flaky").await;
259        let cards = Arc::new(InMemoryCardSource::new());
260        let refresher = CardRefresher::new(registry.clone(), cards.clone());
261
262        refresher.refresh_once().await.unwrap();
263        assert_eq!(liveness_of(&registry, &id).await, Liveness::Unreachable);
264
265        cards
266            .insert("http://flaky", card("Flaky", "forecasting"))
267            .await;
268        refresher.refresh_once().await.unwrap();
269        assert_eq!(liveness_of(&registry, &id).await, Liveness::Live);
270    }
271
272    /// Re-registering is a new claim about where the agent is; what was probed
273    /// was the old address.
274    #[tokio::test]
275    async fn re_registering_resets_liveness() {
276        let registry: Arc<dyn AgentRegistry> = Arc::new(InMemoryAgentRegistry::new());
277        let id = registered(&registry, "Moved", "forecasting", "http://old").await;
278        let cards = InMemoryCardSource::new();
279        cards
280            .insert("http://old", card("Moved", "forecasting"))
281            .await;
282        CardRefresher::new(registry.clone(), Arc::new(cards))
283            .refresh_once()
284            .await
285            .unwrap();
286        assert_eq!(liveness_of(&registry, &id).await, Liveness::Live);
287
288        registered(&registry, "Moved", "forecasting", "http://new").await;
289        assert_eq!(liveness_of(&registry, &id).await, Liveness::Unknown);
290    }
291}