mur_core/a2a/
discovery.rs1use super::protocol::AgentCard;
4use anyhow::{Context, Result};
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10pub struct AgentRegistry {
12 registry_path: PathBuf,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct RegisteredAgent {
19 pub url: String,
21 pub name: String,
23 pub description: String,
25 pub version: String,
27 pub skills: Vec<String>,
29 pub registered_at: DateTime<Utc>,
31 pub last_seen: Option<DateTime<Utc>>,
33 pub healthy: bool,
35 #[serde(default)]
37 pub tags: Vec<String>,
38}
39
40impl AgentRegistry {
41 pub fn new(registry_path: &Path) -> Self {
43 Self {
44 registry_path: registry_path.to_path_buf(),
45 }
46 }
47
48 pub fn default_path() -> PathBuf {
50 directories::BaseDirs::new()
51 .map(|d| d.home_dir().join(".mur").join("commander").join("agents.json"))
52 .expect("Cannot determine home directory for agent registry")
53 }
54
55 pub fn list(&self) -> Result<Vec<RegisteredAgent>> {
57 if !self.registry_path.exists() {
58 return Ok(Vec::new());
59 }
60 let content = std::fs::read_to_string(&self.registry_path)
61 .context("Reading agent registry")?;
62 serde_json::from_str(&content).context("Parsing agent registry")
63 }
64
65 pub fn register(&self, card: &AgentCard) -> Result<RegisteredAgent> {
67 let mut agents = self.list()?;
68
69 let entry = RegisteredAgent {
71 url: card.url.clone(),
72 name: card.name.clone(),
73 description: card.description.clone(),
74 version: card.version.clone(),
75 skills: card.skills.iter().map(|s| s.id.clone()).collect(),
76 registered_at: Utc::now(),
77 last_seen: Some(Utc::now()),
78 healthy: true,
79 tags: vec![],
80 };
81
82 if let Some(existing) = agents.iter_mut().find(|a| a.url == card.url) {
83 *existing = entry.clone();
84 } else {
85 agents.push(entry.clone());
86 }
87
88 self.save(&agents)?;
89 Ok(entry)
90 }
91
92 pub fn remove(&self, url: &str) -> Result<bool> {
94 let mut agents = self.list()?;
95 let before = agents.len();
96 agents.retain(|a| a.url != url);
97 if agents.len() == before {
98 return Ok(false);
99 }
100 self.save(&agents)?;
101 Ok(true)
102 }
103
104 pub fn update_health(&self, url: &str, healthy: bool) -> Result<()> {
106 let mut agents = self.list()?;
107 if let Some(agent) = agents.iter_mut().find(|a| a.url == url) {
108 agent.healthy = healthy;
109 if healthy {
110 agent.last_seen = Some(Utc::now());
111 }
112 }
113 self.save(&agents)?;
114 Ok(())
115 }
116
117 pub fn find_by_skill(&self, skill_id: &str) -> Result<Vec<RegisteredAgent>> {
119 let agents = self.list()?;
120 Ok(agents
121 .into_iter()
122 .filter(|a| a.skills.iter().any(|s| s == skill_id))
123 .collect())
124 }
125
126 pub fn healthy_agents(&self) -> Result<Vec<RegisteredAgent>> {
128 let agents = self.list()?;
129 Ok(agents.into_iter().filter(|a| a.healthy).collect())
130 }
131
132 fn save(&self, agents: &[RegisteredAgent]) -> Result<()> {
134 if let Some(parent) = self.registry_path.parent() {
135 std::fs::create_dir_all(parent)?;
136 }
137 let json = serde_json::to_string_pretty(agents)?;
138 std::fs::write(&self.registry_path, json).context("Writing agent registry")?;
139 Ok(())
140 }
141}
142
143pub fn well_known_url(base_url: &str) -> String {
145 format!(
146 "{}/.well-known/agent.json",
147 base_url.trim_end_matches('/')
148 )
149}
150
151pub async fn discover_many(
153 client: &reqwest::Client,
154 urls: &[String],
155) -> HashMap<String, Result<AgentCard>> {
156 let mut results = HashMap::new();
157
158 for url in urls {
159 let well_known = well_known_url(url);
160 let result = async {
161 let resp = client
162 .get(&well_known)
163 .send()
164 .await
165 .with_context(|| format!("Fetching {}", well_known))?;
166
167 if !resp.status().is_success() {
168 anyhow::bail!("HTTP {}", resp.status());
169 }
170
171 resp.json::<AgentCard>()
172 .await
173 .context("Parsing agent card")
174 }
175 .await;
176
177 results.insert(url.clone(), result);
178 }
179
180 results
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use tempfile::TempDir;
187
188 fn test_registry() -> (TempDir, AgentRegistry) {
189 let dir = TempDir::new().unwrap();
190 let path = dir.path().join("agents.json");
191 (dir, AgentRegistry::new(&path))
192 }
193
194 fn test_card() -> AgentCard {
195 use super::super::protocol::*;
196 AgentCard {
197 name: "Test Agent".into(),
198 description: "A test agent".into(),
199 url: "http://localhost:8080/a2a".into(),
200 version: "1.0.0".into(),
201 protocol_version: "0.1".into(),
202 capabilities: AgentCapabilities {
203 streaming: false,
204 push_notifications: false,
205 state_management: false,
206 },
207 skills: vec![AgentSkill {
208 id: "test-skill".into(),
209 name: "Test".into(),
210 description: "A test skill".into(),
211 tags: vec![],
212 input_schema: None,
213 output_schema: None,
214 }],
215 authentication: None,
216 }
217 }
218
219 #[test]
220 fn test_empty_registry() {
221 let (_dir, registry) = test_registry();
222 let agents = registry.list().unwrap();
223 assert!(agents.is_empty());
224 }
225
226 #[test]
227 fn test_register_agent() {
228 let (_dir, registry) = test_registry();
229 let card = test_card();
230 let entry = registry.register(&card).unwrap();
231 assert_eq!(entry.name, "Test Agent");
232 assert!(entry.healthy);
233
234 let agents = registry.list().unwrap();
235 assert_eq!(agents.len(), 1);
236 }
237
238 #[test]
239 fn test_register_updates_existing() {
240 let (_dir, registry) = test_registry();
241 let card = test_card();
242 registry.register(&card).unwrap();
243 registry.register(&card).unwrap();
244
245 let agents = registry.list().unwrap();
246 assert_eq!(agents.len(), 1); }
248
249 #[test]
250 fn test_remove_agent() {
251 let (_dir, registry) = test_registry();
252 let card = test_card();
253 registry.register(&card).unwrap();
254
255 let removed = registry.remove("http://localhost:8080/a2a").unwrap();
256 assert!(removed);
257
258 let removed_again = registry.remove("http://localhost:8080/a2a").unwrap();
259 assert!(!removed_again);
260
261 assert!(registry.list().unwrap().is_empty());
262 }
263
264 #[test]
265 fn test_update_health() {
266 let (_dir, registry) = test_registry();
267 let card = test_card();
268 registry.register(&card).unwrap();
269
270 registry
271 .update_health("http://localhost:8080/a2a", false)
272 .unwrap();
273
274 let agents = registry.list().unwrap();
275 assert!(!agents[0].healthy);
276 }
277
278 #[test]
279 fn test_find_by_skill() {
280 let (_dir, registry) = test_registry();
281 let card = test_card();
282 registry.register(&card).unwrap();
283
284 let found = registry.find_by_skill("test-skill").unwrap();
285 assert_eq!(found.len(), 1);
286
287 let not_found = registry.find_by_skill("nonexistent").unwrap();
288 assert!(not_found.is_empty());
289 }
290
291 #[test]
292 fn test_healthy_agents() {
293 let (_dir, registry) = test_registry();
294 let card = test_card();
295 registry.register(&card).unwrap();
296
297 let healthy = registry.healthy_agents().unwrap();
298 assert_eq!(healthy.len(), 1);
299
300 registry
301 .update_health("http://localhost:8080/a2a", false)
302 .unwrap();
303 let healthy = registry.healthy_agents().unwrap();
304 assert!(healthy.is_empty());
305 }
306
307 #[test]
308 fn test_well_known_url() {
309 assert_eq!(
310 well_known_url("http://localhost:3939"),
311 "http://localhost:3939/.well-known/agent.json"
312 );
313 assert_eq!(
314 well_known_url("http://localhost:3939/"),
315 "http://localhost:3939/.well-known/agent.json"
316 );
317 }
318}