Skip to main content

synaptic_deep/
skill.rs

1//! Skill/Plugin system for Deep agents.
2//!
3//! A [`Skill`] bundles a set of tools with metadata ([`SkillManifest`]).
4//! The [`SkillRegistry`] manages loaded skills, collects their tools,
5//! and aggregates system-prompt fragments.
6
7use async_trait::async_trait;
8use serde::{Deserialize, Serialize};
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use synaptic_core::{SynapticError, Tool};
12
13/// Metadata about a skill.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct SkillManifest {
16    /// Unique skill identifier (e.g. "web-search", "code-review").
17    pub name: String,
18    /// Human-readable description.
19    pub description: String,
20    /// Semantic version.
21    pub version: String,
22    /// Author or maintainer.
23    pub author: Option<String>,
24    /// Tags for discovery.
25    #[serde(default)]
26    pub tags: Vec<String>,
27}
28
29/// A skill bundles a set of tools with metadata.
30#[async_trait]
31pub trait Skill: Send + Sync {
32    /// Return the skill manifest.
33    fn manifest(&self) -> &SkillManifest;
34
35    /// Return the tools provided by this skill.
36    fn tools(&self) -> Vec<Arc<dyn Tool>>;
37
38    /// Optional system prompt fragment to inject when this skill is active.
39    fn system_prompt_fragment(&self) -> Option<String> {
40        None
41    }
42
43    /// Initialize the skill (called once when loaded).
44    async fn init(&self) -> Result<(), SynapticError> {
45        Ok(())
46    }
47}
48
49/// Registry that manages loaded skills.
50pub struct SkillRegistry {
51    skills: Vec<Arc<dyn Skill>>,
52}
53
54impl SkillRegistry {
55    pub fn new() -> Self {
56        Self { skills: Vec::new() }
57    }
58
59    /// Register a skill.
60    pub fn register(&mut self, skill: Arc<dyn Skill>) {
61        self.skills.push(skill);
62    }
63
64    /// Get all registered skills.
65    pub fn skills(&self) -> &[Arc<dyn Skill>] {
66        &self.skills
67    }
68
69    /// Find a skill by name.
70    pub fn get(&self, name: &str) -> Option<&Arc<dyn Skill>> {
71        self.skills.iter().find(|s| s.manifest().name == name)
72    }
73
74    /// Collect all tools from all registered skills.
75    pub fn all_tools(&self) -> Vec<Arc<dyn Tool>> {
76        self.skills.iter().flat_map(|s| s.tools()).collect()
77    }
78
79    /// Collect system prompt fragments from all skills.
80    pub fn system_prompt_additions(&self) -> String {
81        self.skills
82            .iter()
83            .filter_map(|s| s.system_prompt_fragment())
84            .collect::<Vec<_>>()
85            .join("\n\n")
86    }
87
88    /// Initialize all skills.
89    pub async fn init_all(&self) -> Result<(), SynapticError> {
90        for skill in &self.skills {
91            skill.init().await?;
92        }
93        Ok(())
94    }
95}
96
97impl Default for SkillRegistry {
98    fn default() -> Self {
99        Self::new()
100    }
101}
102
103/// Load a skill manifest from a TOML file.
104pub fn load_manifest(path: &Path) -> Result<SkillManifest, SynapticError> {
105    let content = std::fs::read_to_string(path)
106        .map_err(|e| SynapticError::Config(format!("cannot read manifest: {}", e)))?;
107    toml::from_str(&content).map_err(|e| SynapticError::Config(format!("invalid manifest: {}", e)))
108}
109
110/// Discover skill manifests in a directory (looks for manifest.toml files).
111pub fn discover_skills(dir: &Path) -> Vec<(PathBuf, SkillManifest)> {
112    let mut results = Vec::new();
113    if let Ok(entries) = std::fs::read_dir(dir) {
114        for entry in entries.flatten() {
115            let manifest_path = entry.path().join("manifest.toml");
116            if manifest_path.exists() {
117                if let Ok(manifest) = load_manifest(&manifest_path) {
118                    results.push((entry.path(), manifest));
119                }
120            }
121        }
122    }
123    results
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    struct TestSkill {
131        manifest: SkillManifest,
132    }
133
134    #[async_trait]
135    impl Skill for TestSkill {
136        fn manifest(&self) -> &SkillManifest {
137            &self.manifest
138        }
139        fn tools(&self) -> Vec<Arc<dyn Tool>> {
140            vec![]
141        }
142        fn system_prompt_fragment(&self) -> Option<String> {
143            Some("I am a test skill.".to_string())
144        }
145    }
146
147    #[test]
148    fn test_registry_register_and_get() {
149        let mut registry = SkillRegistry::new();
150        let skill = Arc::new(TestSkill {
151            manifest: SkillManifest {
152                name: "test-skill".to_string(),
153                description: "A test skill".to_string(),
154                version: "1.0.0".to_string(),
155                author: None,
156                tags: vec!["test".to_string()],
157            },
158        });
159        registry.register(skill);
160        assert_eq!(registry.skills().len(), 1);
161        assert!(registry.get("test-skill").is_some());
162        assert!(registry.get("nonexistent").is_none());
163    }
164
165    #[test]
166    fn test_all_tools_empty() {
167        let mut registry = SkillRegistry::new();
168        let skill = Arc::new(TestSkill {
169            manifest: SkillManifest {
170                name: "empty".to_string(),
171                description: "No tools".to_string(),
172                version: "0.1.0".to_string(),
173                author: None,
174                tags: vec![],
175            },
176        });
177        registry.register(skill);
178        assert!(registry.all_tools().is_empty());
179    }
180
181    #[test]
182    fn test_system_prompt_additions() {
183        let mut registry = SkillRegistry::new();
184        registry.register(Arc::new(TestSkill {
185            manifest: SkillManifest {
186                name: "s1".to_string(),
187                description: "".to_string(),
188                version: "0.1.0".to_string(),
189                author: None,
190                tags: vec![],
191            },
192        }));
193        let additions = registry.system_prompt_additions();
194        assert!(additions.contains("test skill"));
195    }
196
197    #[test]
198    fn test_discover_skills_empty_dir() {
199        let dir = std::env::temp_dir().join("synaptic_test_discover_empty");
200        let _ = std::fs::create_dir_all(&dir);
201        let results = discover_skills(&dir);
202        // Just ensure it doesn't panic. Results depend on filesystem.
203        let _ = results;
204        let _ = std::fs::remove_dir_all(&dir);
205    }
206
207    #[tokio::test]
208    async fn test_init_all() {
209        let mut registry = SkillRegistry::new();
210        registry.register(Arc::new(TestSkill {
211            manifest: SkillManifest {
212                name: "init-test".to_string(),
213                description: "".to_string(),
214                version: "0.1.0".to_string(),
215                author: None,
216                tags: vec![],
217            },
218        }));
219        assert!(registry.init_all().await.is_ok());
220    }
221}