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