gemini_adk_rs/
skills.rs

1//! Skill registry — centralized publish/discover for agent capabilities,
2//! the ADK skills-registry pattern.
3//!
4//! A *skill* is a named, versioned capability: locally, an [`AgentConfig`]
5//! this process can build and run; remotely, an A2A endpoint another
6//! service exposes. Registries let a deployment resolve capabilities at
7//! runtime instead of hardcoding them:
8//!
9//! ```ignore
10//! let registry = LocalSkillRegistry::new();
11//! registry.publish(SkillInfo::local("triage", "1.2.0", "Route a support ticket", config)).await?;
12//!
13//! // Later — possibly a different subsystem:
14//! let skill = registry.resolve("triage", None).await?.expect("registered");
15//! let config = skill.agent.unwrap();
16//! ```
17//!
18//! [`LocalSkillRegistry`] is the in-process reference backend, and
19//! [`LocalSkillRegistry::load_dir`] hydrates one from a directory of agent
20//! config files (see [`discover_agent_configs`](crate::agent_config::discover_agent_configs)).
21//! Cloud registries (a database, a service mesh, Google Cloud's skill
22//! registry) implement [`SkillRegistryBackend`].
23
24use 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/// One published skill: identity, humans-facing description, and either a
34/// locally buildable agent, a remote A2A endpoint, or both.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct SkillInfo {
37    /// Skill name — the lookup key.
38    pub name: String,
39    /// Version string; ordering is lexicographic on `(len, str)` so plain
40    /// numeric schemes ("2" < "10") and dotted schemes sort usefully.
41    pub version: String,
42    /// What the skill does — surfaced to humans and to routing LLMs.
43    pub description: String,
44    /// Free-form discovery tags.
45    #[serde(default, skip_serializing_if = "Vec::is_empty")]
46    pub tags: Vec<String>,
47    /// Locally buildable definition, when this process can run the skill.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub agent: Option<AgentConfig>,
50    /// Remote A2A endpoint URL, when the skill runs elsewhere.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub endpoint: Option<String>,
53}
54
55impl SkillInfo {
56    /// A locally runnable skill backed by an [`AgentConfig`].
57    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    /// A remote skill reachable over A2A.
74    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    /// Add discovery tags.
91    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/// Filter for [`SkillRegistryBackend::list`]. Empty filter matches all.
98#[derive(Debug, Clone, Default)]
99pub struct SkillFilter {
100    /// Substring match against the skill name (case-insensitive).
101    pub name_contains: Option<String>,
102    /// Require this tag.
103    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/// Storage backend for a skill registry. Implement this for a database or
123/// cloud registry; [`LocalSkillRegistry`] is the in-process reference.
124#[async_trait]
125pub trait SkillRegistryBackend: Send + Sync {
126    /// Publish (or re-publish) a skill version. Same name+version replaces.
127    async fn publish(&self, skill: SkillInfo) -> Result<(), SkillRegistryError>;
128    /// Resolve a skill: the exact version when given, else the latest.
129    async fn resolve(
130        &self,
131        name: &str,
132        version: Option<&str>,
133    ) -> Result<Option<SkillInfo>, SkillRegistryError>;
134    /// List skills matching the filter — the latest version of each name.
135    async fn list(&self, filter: &SkillFilter) -> Result<Vec<SkillInfo>, SkillRegistryError>;
136    /// Remove every version of a skill. Missing names are a no-op.
137    async fn remove(&self, name: &str) -> Result<(), SkillRegistryError>;
138}
139
140/// Registry backend failure.
141#[derive(Debug, thiserror::Error)]
142pub enum SkillRegistryError {
143    /// The backend rejected or failed the operation.
144    #[error("skill registry: {0}")]
145    Backend(String),
146}
147
148/// Version ordering: length-then-lexicographic, so "2" < "10" and
149/// "1.9.0" < "1.10.0" without pulling in a semver dependency.
150fn 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/// In-process skill registry: a versioned map behind an async lock.
160#[derive(Default)]
161pub struct LocalSkillRegistry {
162    skills: RwLock<HashMap<String, Vec<SkillInfo>>>,
163}
164
165impl LocalSkillRegistry {
166    /// An empty registry.
167    pub fn new() -> Self {
168        Self::default()
169    }
170
171    /// Hydrate a registry from a directory of agent config files: every
172    /// discovered [`AgentConfig`] is published as version `"0"` under its
173    /// own name with its description.
174    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    /// Wrap in an [`Arc`] for sharing across tasks.
191    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        // Latest is numeric-aware: 1.10.0 beats 1.9.0.
269        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}