Skip to main content

mur_core/a2a/
discovery.rs

1//! A2A Discovery — well-known URLs and local agent registry.
2
3use 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
10/// Local registry of known A2A agents.
11pub struct AgentRegistry {
12    /// Path to the registry JSON file.
13    registry_path: PathBuf,
14}
15
16/// A registered agent entry.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct RegisteredAgent {
19    /// Base URL of the agent.
20    pub url: String,
21    /// Agent name (from card).
22    pub name: String,
23    /// Agent description.
24    pub description: String,
25    /// Agent version.
26    pub version: String,
27    /// Skills offered.
28    pub skills: Vec<String>,
29    /// When the agent was registered.
30    pub registered_at: DateTime<Utc>,
31    /// When the agent was last seen healthy.
32    pub last_seen: Option<DateTime<Utc>>,
33    /// Whether the agent is currently reachable.
34    pub healthy: bool,
35    /// Optional tags for categorization.
36    #[serde(default)]
37    pub tags: Vec<String>,
38}
39
40impl AgentRegistry {
41    /// Create a new agent registry.
42    pub fn new(registry_path: &Path) -> Self {
43        Self {
44            registry_path: registry_path.to_path_buf(),
45        }
46    }
47
48    /// Default registry path in the commander data directory.
49    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    /// List all registered agents.
56    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    /// Register an agent from its card.
66    pub fn register(&self, card: &AgentCard) -> Result<RegisteredAgent> {
67        let mut agents = self.list()?;
68
69        // Update existing or add new
70        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    /// Remove an agent by URL.
93    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    /// Update the health status of an agent.
105    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    /// Find agents that have a specific skill.
118    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    /// Find healthy agents only.
127    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    /// Save the registry to disk.
133    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
143/// Construct the well-known agent card URL from a base URL.
144pub fn well_known_url(base_url: &str) -> String {
145    format!(
146        "{}/.well-known/agent.json",
147        base_url.trim_end_matches('/')
148    )
149}
150
151/// Discover agents from a list of well-known URLs.
152pub 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); // Should not duplicate
247    }
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}