Skip to main content

a3s_code_core/
plugin.rs

1//! Plugin System for A3S Code
2//!
3//! Provides a unified interface for loading and unloading optional tool plugins.
4//! All plugins implement the [`Plugin`] trait, which gives the system a single
5//! consistent surface for lifecycle management.
6//!
7//! # Usage
8//!
9//! ```rust,ignore
10//! use a3s_code_core::{SessionOptions, SkillPlugin};
11//!
12//! let opts = SessionOptions::new()
13//!     .with_plugin(SkillPlugin::new("custom"));
14//! ```
15
16use crate::skills::Skill;
17use crate::tools::ToolRegistry;
18use anyhow::Result;
19use std::sync::Arc;
20
21// ============================================================================
22// Plugin context — passed to every plugin on load
23// ============================================================================
24
25/// Runtime context provided to plugins when they are loaded.
26///
27/// Gives plugins access to dependencies they may need (LLM client,
28/// skill registry, document parser registry, etc.) without coupling the
29/// `Plugin` trait to specific concrete types.
30#[derive(Clone)]
31pub struct PluginContext {
32    /// LLM client — required by tools that do LLM inference (e.g. agentic_parse).
33    pub llm: Option<Arc<dyn crate::llm::LlmClient>>,
34    /// Document parser registry — required by document-aware tools.
35    pub document_parsers: Option<Arc<crate::document_parser::DocumentParserRegistry>>,
36    /// Skill registry — plugins may register companion skills here.
37    pub skill_registry: Option<Arc<crate::skills::SkillRegistry>>,
38}
39
40impl PluginContext {
41    pub fn new() -> Self {
42        Self {
43            llm: None,
44            document_parsers: None,
45            skill_registry: None,
46        }
47    }
48
49    pub fn with_llm(mut self, llm: Arc<dyn crate::llm::LlmClient>) -> Self {
50        self.llm = Some(llm);
51        self
52    }
53
54    pub fn with_document_parsers(
55        mut self,
56        registry: Arc<crate::document_parser::DocumentParserRegistry>,
57    ) -> Self {
58        self.document_parsers = Some(registry);
59        self
60    }
61
62    pub fn with_skill_registry(mut self, registry: Arc<crate::skills::SkillRegistry>) -> Self {
63        self.skill_registry = Some(registry);
64        self
65    }
66}
67
68impl Default for PluginContext {
69    fn default() -> Self {
70        Self::new()
71    }
72}
73
74// ============================================================================
75// Plugin trait
76// ============================================================================
77
78/// Unified interface for all A3S Code plugins.
79///
80/// A plugin is a self-contained unit that registers one or more tools into a
81/// `ToolRegistry` when loaded and removes them when unloaded.  This gives the
82/// host application a single consistent API for managing optional capabilities.
83///
84/// # Implementing a plugin
85///
86/// ```rust,ignore
87/// use a3s_code_core::plugin::{Plugin, PluginContext};
88/// use a3s_code_core::tools::ToolRegistry;
89/// use anyhow::Result;
90/// use std::sync::Arc;
91///
92/// struct MyPlugin;
93///
94/// impl Plugin for MyPlugin {
95///     fn name(&self) -> &str { "my-plugin" }
96///     fn version(&self) -> &str { "0.1.0" }
97///     fn tool_names(&self) -> &[&str] { &["my_tool"] }
98///     fn load(&self, registry: &Arc<ToolRegistry>, _ctx: &PluginContext) -> Result<()> {
99///         Ok(())
100///     }
101/// }
102/// ```
103pub trait Plugin: Send + Sync {
104    /// Unique plugin identifier (kebab-case, e.g. `"agentic-search"`).
105    fn name(&self) -> &str;
106
107    /// Plugin version string (semver, e.g. `"1.0.0"`).
108    fn version(&self) -> &str;
109
110    /// Names of all tools this plugin registers.
111    ///
112    /// Used by `PluginManager::unload` to remove the correct tools.
113    fn tool_names(&self) -> &[&str];
114
115    /// Register this plugin's tools into `registry`.
116    ///
117    /// Called once when the plugin is mounted onto a session.
118    fn load(&self, registry: &Arc<ToolRegistry>, ctx: &PluginContext) -> Result<()>;
119
120    /// Remove this plugin's tools from `registry`.
121    ///
122    /// The default implementation unregisters every tool listed in
123    /// `tool_names()`.  Override only if you need custom cleanup.
124    fn unload(&self, registry: &Arc<ToolRegistry>) {
125        for name in self.tool_names() {
126            registry.unregister(name);
127        }
128    }
129
130    /// Human-readable description shown in plugin listings.
131    fn description(&self) -> &str {
132        ""
133    }
134
135    /// Skills bundled with this plugin.
136    ///
137    /// When the plugin is loaded successfully, each skill returned here is
138    /// registered into `PluginContext::skill_registry` (if one is provided).
139    /// This allows the skill to appear in the system prompt and be matched
140    /// against user requests automatically — no manual skill configuration
141    /// is needed by the caller.
142    ///
143    /// Override to return plugin-specific skills. The default returns an
144    /// empty list (no companion skills).
145    fn skills(&self) -> Vec<Arc<Skill>> {
146        vec![]
147    }
148}
149
150// ============================================================================
151// PluginManager
152// ============================================================================
153
154/// Manages the lifecycle of all loaded plugins for a session.
155///
156/// Each session owns its own `PluginManager`; plugins are not shared across
157/// sessions so that sessions can have different capability sets.
158#[derive(Default)]
159pub struct PluginManager {
160    plugins: Vec<Arc<dyn Plugin>>,
161}
162
163impl PluginManager {
164    pub fn new() -> Self {
165        Self::default()
166    }
167
168    /// Register a plugin. Does not load it yet.
169    pub fn register(&mut self, plugin: impl Plugin + 'static) {
170        self.plugins.push(Arc::new(plugin));
171    }
172
173    /// Register a pre-boxed plugin.
174    pub fn register_arc(&mut self, plugin: Arc<dyn Plugin>) {
175        self.plugins.push(plugin);
176    }
177
178    /// Load all registered plugins into `registry`.
179    ///
180    /// Plugins are loaded in registration order.  If a plugin fails to load,
181    /// the error is logged and loading continues for the remaining plugins.
182    ///
183    /// On a successful load, the plugin's companion skills (from [`Plugin::skills`])
184    /// are registered into `ctx.skill_registry` when one is provided.
185    pub fn load_all(&self, registry: &Arc<ToolRegistry>, ctx: &PluginContext) {
186        for plugin in &self.plugins {
187            tracing::info!("Loading plugin '{}' v{}", plugin.name(), plugin.version());
188            match plugin.load(registry, ctx) {
189                Ok(()) => {
190                    if let Some(ref skill_reg) = ctx.skill_registry {
191                        for skill in plugin.skills() {
192                            tracing::debug!(
193                                "Plugin '{}' registered skill '{}'",
194                                plugin.name(),
195                                skill.name
196                            );
197                            skill_reg.register_unchecked(skill);
198                        }
199                    }
200                }
201                Err(e) => {
202                    tracing::error!("Plugin '{}' failed to load: {}", plugin.name(), e);
203                }
204            }
205        }
206    }
207
208    /// Unload a single plugin by name.
209    ///
210    /// Removes the plugin's tools from the registry and deregisters the plugin.
211    pub fn unload(&mut self, name: &str, registry: &Arc<ToolRegistry>) {
212        if let Some(pos) = self.plugins.iter().position(|p| p.name() == name) {
213            let plugin = self.plugins.remove(pos);
214            tracing::info!("Unloading plugin '{}'", plugin.name());
215            plugin.unload(registry);
216        }
217    }
218
219    /// Unload all plugins.
220    pub fn unload_all(&mut self, registry: &Arc<ToolRegistry>) {
221        for plugin in self.plugins.drain(..).rev() {
222            tracing::info!("Unloading plugin '{}'", plugin.name());
223            plugin.unload(registry);
224        }
225    }
226
227    /// Returns `true` if a plugin with `name` is registered.
228    pub fn is_loaded(&self, name: &str) -> bool {
229        self.plugins.iter().any(|p| p.name() == name)
230    }
231
232    /// Number of registered plugins.
233    pub fn len(&self) -> usize {
234        self.plugins.len()
235    }
236
237    /// Returns `true` if no plugins are registered.
238    pub fn is_empty(&self) -> bool {
239        self.plugins.is_empty()
240    }
241
242    /// List all registered plugin names.
243    pub fn plugin_names(&self) -> Vec<&str> {
244        self.plugins.iter().map(|p| p.name()).collect()
245    }
246}
247
248// ============================================================================
249// SkillPlugin — skill-only plugin (no tools)
250// ============================================================================
251
252/// A skill-only plugin that injects custom skills into the session's skill
253/// registry without registering any tools.
254///
255/// This is the primary way to add custom LLM guidance from Python or Node.js
256/// without writing Rust. Provide skill YAML/markdown content strings and they
257/// will appear in the system prompt automatically.
258///
259/// # Example
260///
261/// ```rust,ignore
262/// let plugin = SkillPlugin::new("my-plugin")
263///     .with_skill(r#"---
264/// name: my-skill
265/// description: Use bash when running shell commands
266/// allowed-tools: "bash(*)"
267/// kind: instruction
268/// ---
269/// Always explain what command you're about to run before executing it."#);
270///
271/// let opts = SessionOptions::new().with_plugin(plugin);
272/// ```
273pub struct SkillPlugin {
274    plugin_name: String,
275    plugin_version: String,
276    skill_contents: Vec<String>,
277}
278
279impl SkillPlugin {
280    pub fn new(name: impl Into<String>) -> Self {
281        Self {
282            plugin_name: name.into(),
283            plugin_version: "1.0.0".into(),
284            skill_contents: vec![],
285        }
286    }
287
288    pub fn with_skill(mut self, content: impl Into<String>) -> Self {
289        self.skill_contents.push(content.into());
290        self
291    }
292
293    pub fn with_skills(mut self, contents: impl IntoIterator<Item = impl Into<String>>) -> Self {
294        self.skill_contents
295            .extend(contents.into_iter().map(|s| s.into()));
296        self
297    }
298}
299
300impl Plugin for SkillPlugin {
301    fn name(&self) -> &str {
302        &self.plugin_name
303    }
304
305    fn version(&self) -> &str {
306        &self.plugin_version
307    }
308
309    fn tool_names(&self) -> &[&str] {
310        &[]
311    }
312
313    fn load(&self, _registry: &Arc<ToolRegistry>, _ctx: &PluginContext) -> Result<()> {
314        Ok(())
315    }
316
317    fn skills(&self) -> Vec<Arc<Skill>> {
318        self.skill_contents
319            .iter()
320            .filter_map(|content| Skill::parse(content).map(Arc::new))
321            .collect()
322    }
323}
324
325// ============================================================================
326// Tests
327// ============================================================================
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use crate::tools::ToolRegistry;
333    use std::path::PathBuf;
334
335    fn make_registry() -> Arc<ToolRegistry> {
336        Arc::new(ToolRegistry::new(PathBuf::from("/tmp")))
337    }
338
339    #[test]
340    fn plugin_manager_register_and_query() {
341        let mut mgr = PluginManager::new();
342        assert!(mgr.is_empty());
343        mgr.register(SkillPlugin::new("example"));
344        assert_eq!(mgr.len(), 1);
345        assert!(mgr.is_loaded("example"));
346    }
347
348    #[test]
349    fn plugin_manager_load_all() {
350        let mut mgr = PluginManager::new();
351        mgr.register(SkillPlugin::new("example"));
352        let registry = make_registry();
353        let ctx = PluginContext::new();
354        mgr.load_all(&registry, &ctx);
355        assert!(registry.get("example").is_none());
356    }
357
358    #[test]
359    fn plugin_manager_unload() {
360        let mut mgr = PluginManager::new();
361        mgr.register(SkillPlugin::new("example"));
362        let registry = make_registry();
363        let ctx = PluginContext::new();
364        mgr.load_all(&registry, &ctx);
365        mgr.unload("example", &registry);
366        assert!(!mgr.is_loaded("example"));
367    }
368
369    #[test]
370    fn plugin_manager_unload_all() {
371        let mut mgr = PluginManager::new();
372        mgr.register(SkillPlugin::new("example"));
373        let registry = make_registry();
374        let ctx = PluginContext::new();
375        mgr.load_all(&registry, &ctx);
376        mgr.unload_all(&registry);
377        assert!(mgr.is_empty());
378    }
379
380    #[test]
381    fn plugin_skills_registered_on_load_all() {
382        use crate::skills::SkillRegistry;
383
384        let mut mgr = PluginManager::new();
385        mgr.register(SkillPlugin::new("test-plugin").with_skill(
386            r#"---
387name: test-skill
388description: Test skill
389allowed-tools: "read(*)"
390kind: instruction
391---
392Read carefully."#,
393        ));
394
395        let registry = make_registry();
396        let skill_reg = Arc::new(SkillRegistry::new());
397        let ctx = PluginContext::new().with_skill_registry(Arc::clone(&skill_reg));
398
399        mgr.load_all(&registry, &ctx);
400        assert!(skill_reg.get("test-skill").is_some());
401    }
402
403    #[test]
404    fn plugin_skills_not_registered_when_no_skill_registry_in_ctx() {
405        let mut mgr = PluginManager::new();
406        mgr.register(SkillPlugin::new("test-plugin"));
407
408        let registry = make_registry();
409        // No skill_registry in ctx
410        let ctx = PluginContext::new();
411        mgr.load_all(&registry, &ctx);
412        // No crash — skill registry absence is silently tolerated
413    }
414
415    #[test]
416    fn skill_plugin_no_tools_and_injects_skills() {
417        use crate::skills::SkillRegistry;
418
419        let skill_md = r#"---
420name: test-skill
421description: Test skill
422allowed-tools: "bash(*)"
423kind: instruction
424---
425Test instruction."#;
426
427        let mut mgr = PluginManager::new();
428        mgr.register(SkillPlugin::new("test-plugin").with_skill(skill_md));
429
430        let registry = make_registry();
431        let skill_reg = Arc::new(SkillRegistry::new());
432        let ctx = PluginContext::new().with_skill_registry(Arc::clone(&skill_reg));
433
434        mgr.load_all(&registry, &ctx);
435
436        // No tools registered
437        assert!(registry.get("test-plugin").is_none());
438        // Skill registered
439        assert!(skill_reg.get("test-skill").is_some());
440    }
441
442    #[test]
443    fn skill_plugin_with_skills_builder() {
444        let skill1 = "---\nname: s1\ndescription: d1\nkind: instruction\n---\nContent 1";
445        let skill2 = "---\nname: s2\ndescription: d2\nkind: instruction\n---\nContent 2";
446
447        let plugin = SkillPlugin::new("multi").with_skills([skill1, skill2]);
448        assert_eq!(plugin.skills().len(), 2);
449    }
450
451    #[test]
452    fn plugin_names() {
453        let mut mgr = PluginManager::new();
454        mgr.register(SkillPlugin::new("a"));
455        mgr.register(SkillPlugin::new("b"));
456        let names = mgr.plugin_names();
457        assert!(names.contains(&"a"));
458        assert!(names.contains(&"b"));
459    }
460}