1use std::collections::HashMap;
25use std::sync::Arc;
26
27use async_trait::async_trait;
28use serde::{Deserialize, Serialize};
29use tokio::sync::RwLock;
30
31use crate::agent_config::AgentConfig;
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct SkillInfo {
37 pub name: String,
39 pub version: String,
42 pub description: String,
44 #[serde(default, skip_serializing_if = "Vec::is_empty")]
46 pub tags: Vec<String>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub agent: Option<AgentConfig>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub endpoint: Option<String>,
53}
54
55impl SkillInfo {
56 pub fn local(
58 name: impl Into<String>,
59 version: impl Into<String>,
60 description: impl Into<String>,
61 agent: AgentConfig,
62 ) -> Self {
63 Self {
64 name: name.into(),
65 version: version.into(),
66 description: description.into(),
67 tags: Vec::new(),
68 agent: Some(agent),
69 endpoint: None,
70 }
71 }
72
73 pub fn remote(
75 name: impl Into<String>,
76 version: impl Into<String>,
77 description: impl Into<String>,
78 endpoint: impl Into<String>,
79 ) -> Self {
80 Self {
81 name: name.into(),
82 version: version.into(),
83 description: description.into(),
84 tags: Vec::new(),
85 agent: None,
86 endpoint: Some(endpoint.into()),
87 }
88 }
89
90 pub fn with_tags(mut self, tags: &[&str]) -> Self {
92 self.tags = tags.iter().map(std::string::ToString::to_string).collect();
93 self
94 }
95}
96
97#[derive(Debug, Clone, Default)]
99pub struct SkillFilter {
100 pub name_contains: Option<String>,
102 pub tag: Option<String>,
104}
105
106impl SkillFilter {
107 fn matches(&self, skill: &SkillInfo) -> bool {
108 if let Some(needle) = &self.name_contains
109 && !skill.name.to_lowercase().contains(&needle.to_lowercase())
110 {
111 return false;
112 }
113 if let Some(tag) = &self.tag
114 && !skill.tags.iter().any(|t| t == tag)
115 {
116 return false;
117 }
118 true
119 }
120}
121
122#[async_trait]
125pub trait SkillRegistryBackend: Send + Sync {
126 async fn publish(&self, skill: SkillInfo) -> Result<(), SkillRegistryError>;
128 async fn resolve(
130 &self,
131 name: &str,
132 version: Option<&str>,
133 ) -> Result<Option<SkillInfo>, SkillRegistryError>;
134 async fn list(&self, filter: &SkillFilter) -> Result<Vec<SkillInfo>, SkillRegistryError>;
136 async fn remove(&self, name: &str) -> Result<(), SkillRegistryError>;
138}
139
140#[derive(Debug, thiserror::Error)]
142pub enum SkillRegistryError {
143 #[error("skill registry: {0}")]
145 Backend(String),
146}
147
148fn version_key(v: &str) -> (Vec<(usize, String)>, String) {
151 (
152 v.split('.')
153 .map(|part| (part.len(), part.to_string()))
154 .collect(),
155 v.to_string(),
156 )
157}
158
159#[derive(Default)]
161pub struct LocalSkillRegistry {
162 skills: RwLock<HashMap<String, Vec<SkillInfo>>>,
163}
164
165impl LocalSkillRegistry {
166 pub fn new() -> Self {
168 Self::default()
169 }
170
171 pub async fn load_dir(dir: &std::path::Path) -> Result<Self, SkillRegistryError> {
175 let configs = crate::agent_config::discover_agent_configs(dir)
176 .map_err(|e| SkillRegistryError::Backend(e.to_string()))?;
177 let registry = Self::new();
178 for config in configs {
179 let skill = SkillInfo::local(
180 config.name.clone(),
181 "0",
182 config.description.clone().unwrap_or_default(),
183 config,
184 );
185 registry.publish(skill).await?;
186 }
187 Ok(registry)
188 }
189
190 pub fn shared(self) -> Arc<Self> {
192 Arc::new(self)
193 }
194}
195
196#[async_trait]
197impl SkillRegistryBackend for LocalSkillRegistry {
198 async fn publish(&self, skill: SkillInfo) -> Result<(), SkillRegistryError> {
199 if skill.name.is_empty() {
200 return Err(SkillRegistryError::Backend("skill name is empty".into()));
201 }
202 let mut skills = self.skills.write().await;
203 let versions = skills.entry(skill.name.clone()).or_default();
204 versions.retain(|existing| existing.version != skill.version);
205 versions.push(skill);
206 versions.sort_by_key(|s| version_key(&s.version));
207 Ok(())
208 }
209
210 async fn resolve(
211 &self,
212 name: &str,
213 version: Option<&str>,
214 ) -> Result<Option<SkillInfo>, SkillRegistryError> {
215 let skills = self.skills.read().await;
216 let Some(versions) = skills.get(name) else {
217 return Ok(None);
218 };
219 Ok(match version {
220 Some(v) => versions.iter().find(|s| s.version == v).cloned(),
221 None => versions.last().cloned(),
222 })
223 }
224
225 async fn list(&self, filter: &SkillFilter) -> Result<Vec<SkillInfo>, SkillRegistryError> {
226 let skills = self.skills.read().await;
227 let mut out: Vec<SkillInfo> = skills
228 .values()
229 .filter_map(|versions| versions.last())
230 .filter(|s| filter.matches(s))
231 .cloned()
232 .collect();
233 out.sort_by(|a, b| a.name.cmp(&b.name));
234 Ok(out)
235 }
236
237 async fn remove(&self, name: &str) -> Result<(), SkillRegistryError> {
238 self.skills.write().await.remove(name);
239 Ok(())
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 fn config(name: &str) -> AgentConfig {
248 AgentConfig::from_json(&format!(
249 r#"{{"name": "{name}", "instruction": "do the thing"}}"#
250 ))
251 .unwrap()
252 }
253
254 #[tokio::test]
255 async fn publish_resolve_latest_and_exact() {
256 let registry = LocalSkillRegistry::new();
257 for v in ["1.9.0", "1.10.0", "1.2.0"] {
258 registry
259 .publish(SkillInfo::local(
260 "triage",
261 v,
262 "route tickets",
263 config("triage"),
264 ))
265 .await
266 .unwrap();
267 }
268 let latest = registry.resolve("triage", None).await.unwrap().unwrap();
270 assert_eq!(latest.version, "1.10.0");
271 let exact = registry
272 .resolve("triage", Some("1.2.0"))
273 .await
274 .unwrap()
275 .unwrap();
276 assert_eq!(exact.version, "1.2.0");
277 assert!(registry.resolve("missing", None).await.unwrap().is_none());
278 }
279
280 #[tokio::test]
281 async fn republish_replaces_and_list_filters() {
282 let registry = LocalSkillRegistry::new();
283 registry
284 .publish(
285 SkillInfo::local("triage", "1", "old", config("triage")).with_tags(&["support"]),
286 )
287 .await
288 .unwrap();
289 registry
290 .publish(
291 SkillInfo::local("triage", "1", "new", config("triage")).with_tags(&["support"]),
292 )
293 .await
294 .unwrap();
295 registry
296 .publish(SkillInfo::remote(
297 "billing",
298 "1",
299 "invoices",
300 "https://a2a.example",
301 ))
302 .await
303 .unwrap();
304
305 let all = registry.list(&SkillFilter::default()).await.unwrap();
306 assert_eq!(all.len(), 2);
307 assert_eq!(
308 registry
309 .resolve("triage", None)
310 .await
311 .unwrap()
312 .unwrap()
313 .description,
314 "new"
315 );
316 let tagged = registry
317 .list(&SkillFilter {
318 tag: Some("support".into()),
319 ..Default::default()
320 })
321 .await
322 .unwrap();
323 assert_eq!(tagged.len(), 1);
324 assert_eq!(tagged[0].name, "triage");
325
326 registry.remove("triage").await.unwrap();
327 assert!(registry.resolve("triage", None).await.unwrap().is_none());
328 }
329}