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