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::{register_program, register_program_with_catalog, ToolRegistry};
18use anyhow::{bail, 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
289/// Plugin that extends or replaces the model-visible `program` tool catalog.
290///
291/// This is the lightweight asset path for PTC templates: callers can package
292/// `ProgramTemplate` values with a plugin and mount them per session without
293/// writing a new tool.
294pub struct ProgramPlugin {
295    plugin_name: String,
296    plugin_version: String,
297    templates: Vec<crate::program::ProgramTemplate>,
298    include_builtin_programs: bool,
299}
300
301impl ProgramPlugin {
302    pub fn new(name: impl Into<String>) -> Self {
303        Self {
304            plugin_name: name.into(),
305            plugin_version: "1.0.0".into(),
306            templates: Vec::new(),
307            include_builtin_programs: true,
308        }
309    }
310
311    pub fn with_version(mut self, version: impl Into<String>) -> Self {
312        self.plugin_version = version.into();
313        self
314    }
315
316    pub fn with_template(mut self, template: crate::program::ProgramTemplate) -> Self {
317        self.templates.push(template);
318        self
319    }
320
321    pub fn with_templates(
322        mut self,
323        templates: impl IntoIterator<Item = crate::program::ProgramTemplate>,
324    ) -> Self {
325        self.templates.extend(templates);
326        self
327    }
328
329    pub fn without_builtin_programs(mut self) -> Self {
330        self.include_builtin_programs = false;
331        self
332    }
333
334    pub fn from_json(name: impl Into<String>, content: &str) -> Result<Self> {
335        let asset = serde_json::from_str::<ProgramTemplateAsset>(content)?;
336        Ok(Self::new(name).with_templates(asset.into_templates()))
337    }
338
339    pub fn from_yaml(name: impl Into<String>, content: &str) -> Result<Self> {
340        let asset = serde_yaml::from_str::<ProgramTemplateAsset>(content)?;
341        Ok(Self::new(name).with_templates(asset.into_templates()))
342    }
343}
344
345impl Plugin for ProgramPlugin {
346    fn name(&self) -> &str {
347        &self.plugin_name
348    }
349
350    fn version(&self) -> &str {
351        &self.plugin_version
352    }
353
354    fn tool_names(&self) -> &[&str] {
355        &["program"]
356    }
357
358    fn load(&self, registry: &Arc<ToolRegistry>, _ctx: &PluginContext) -> Result<()> {
359        if self.templates.is_empty() {
360            bail!(
361                "ProgramPlugin '{}' has no program templates",
362                self.plugin_name
363            );
364        }
365
366        let mut catalog = if self.include_builtin_programs {
367            crate::program::ProgramCatalog::with_builtin_programs()
368        } else {
369            crate::program::ProgramCatalog::new()
370        };
371        for template in &self.templates {
372            catalog.try_register(template.clone())?;
373        }
374        register_program_with_catalog(registry, catalog);
375        Ok(())
376    }
377
378    fn unload(&self, registry: &Arc<ToolRegistry>) {
379        register_program(registry);
380    }
381
382    fn description(&self) -> &str {
383        "Registers programmatic tool calling templates"
384    }
385}
386
387#[derive(Debug, serde::Deserialize)]
388#[serde(untagged)]
389enum ProgramTemplateAsset {
390    Template(crate::program::ProgramTemplate),
391    Templates(Vec<crate::program::ProgramTemplate>),
392    Catalog {
393        programs: Vec<crate::program::ProgramTemplate>,
394    },
395}
396
397impl ProgramTemplateAsset {
398    fn into_templates(self) -> Vec<crate::program::ProgramTemplate> {
399        match self {
400            Self::Template(template) => vec![template],
401            Self::Templates(templates) => templates,
402            Self::Catalog { programs } => programs,
403        }
404    }
405}
406
407impl Plugin for SkillPlugin {
408    fn name(&self) -> &str {
409        &self.plugin_name
410    }
411
412    fn version(&self) -> &str {
413        &self.plugin_version
414    }
415
416    fn tool_names(&self) -> &[&str] {
417        &[]
418    }
419
420    fn load(&self, _registry: &Arc<ToolRegistry>, _ctx: &PluginContext) -> Result<()> {
421        Ok(())
422    }
423
424    fn skills(&self) -> Vec<Arc<Skill>> {
425        self.skill_contents
426            .iter()
427            .filter_map(|content| Skill::parse(content).map(Arc::new))
428            .collect()
429    }
430}
431
432// ============================================================================
433// Tests
434// ============================================================================
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use crate::tools::{Tool, ToolContext, ToolOutput, ToolRegistry};
440    use async_trait::async_trait;
441    use std::path::PathBuf;
442
443    fn make_registry() -> Arc<ToolRegistry> {
444        Arc::new(ToolRegistry::new(PathBuf::from("/tmp")))
445    }
446
447    struct EchoTool;
448
449    #[async_trait]
450    impl Tool for EchoTool {
451        fn name(&self) -> &str {
452            "echo"
453        }
454
455        fn description(&self) -> &str {
456            "Echoes a message"
457        }
458
459        fn parameters(&self) -> serde_json::Value {
460            serde_json::json!({
461                "type": "object",
462                "additionalProperties": false,
463                "properties": {
464                    "message": { "type": "string" }
465                },
466                "required": ["message"]
467            })
468        }
469
470        async fn execute(
471            &self,
472            args: &serde_json::Value,
473            _ctx: &ToolContext,
474        ) -> Result<ToolOutput> {
475            Ok(ToolOutput::success(
476                args["message"].as_str().unwrap_or_default(),
477            ))
478        }
479    }
480
481    #[test]
482    fn plugin_manager_register_and_query() {
483        let mut mgr = PluginManager::new();
484        assert!(mgr.is_empty());
485        mgr.register(SkillPlugin::new("example"));
486        assert_eq!(mgr.len(), 1);
487        assert!(mgr.is_loaded("example"));
488    }
489
490    #[test]
491    fn plugin_manager_load_all() {
492        let mut mgr = PluginManager::new();
493        mgr.register(SkillPlugin::new("example"));
494        let registry = make_registry();
495        let ctx = PluginContext::new();
496        mgr.load_all(&registry, &ctx);
497        assert!(registry.get("example").is_none());
498    }
499
500    #[test]
501    fn plugin_manager_unload() {
502        let mut mgr = PluginManager::new();
503        mgr.register(SkillPlugin::new("example"));
504        let registry = make_registry();
505        let ctx = PluginContext::new();
506        mgr.load_all(&registry, &ctx);
507        mgr.unload("example", &registry);
508        assert!(!mgr.is_loaded("example"));
509    }
510
511    #[test]
512    fn plugin_manager_unload_all() {
513        let mut mgr = PluginManager::new();
514        mgr.register(SkillPlugin::new("example"));
515        let registry = make_registry();
516        let ctx = PluginContext::new();
517        mgr.load_all(&registry, &ctx);
518        mgr.unload_all(&registry);
519        assert!(mgr.is_empty());
520    }
521
522    #[test]
523    fn plugin_skills_registered_on_load_all() {
524        use crate::skills::SkillRegistry;
525
526        let mut mgr = PluginManager::new();
527        mgr.register(SkillPlugin::new("test-plugin").with_skill(
528            r#"---
529name: test-skill
530description: Test skill
531allowed-tools: "read(*)"
532kind: instruction
533---
534Read carefully."#,
535        ));
536
537        let registry = make_registry();
538        let skill_reg = Arc::new(SkillRegistry::new());
539        let ctx = PluginContext::new().with_skill_registry(Arc::clone(&skill_reg));
540
541        mgr.load_all(&registry, &ctx);
542        assert!(skill_reg.get("test-skill").is_some());
543    }
544
545    #[test]
546    fn plugin_skills_not_registered_when_no_skill_registry_in_ctx() {
547        let mut mgr = PluginManager::new();
548        mgr.register(SkillPlugin::new("test-plugin"));
549
550        let registry = make_registry();
551        // No skill_registry in ctx
552        let ctx = PluginContext::new();
553        mgr.load_all(&registry, &ctx);
554        // No crash — skill registry absence is silently tolerated
555    }
556
557    #[tokio::test]
558    async fn program_plugin_loads_template_catalog_without_reenabling_named_programs() {
559        let registry = make_registry();
560        registry.register(Arc::new(EchoTool));
561        let plugin = ProgramPlugin::new("program-pack").with_template(
562            crate::program::ProgramTemplate::new("custom_echo", "Run a custom echo")
563                .with_parameter(crate::program::ProgramParameter::required(
564                    "message",
565                    "Message to echo",
566                ))
567                .with_step(
568                    crate::program::ProgramStepTemplate::new(
569                        "echo",
570                        serde_json::json!({ "message": "{{message}}" }),
571                    )
572                    .with_label("echo_message"),
573                ),
574        );
575
576        plugin.load(&registry, &PluginContext::new()).unwrap();
577
578        let result = registry
579            .execute_with_context(
580                "program",
581                &serde_json::json!({
582                    "name": "custom_echo",
583                    "inputs": { "message": "hello" }
584                }),
585                &ToolContext::new(PathBuf::from("/tmp")),
586            )
587            .await
588            .unwrap();
589
590        assert_eq!(result.exit_code, 1);
591        assert!(result.output.contains("type parameter is required"));
592    }
593
594    #[tokio::test]
595    async fn program_plugin_can_load_templates_from_yaml_asset_without_named_execution() {
596        let registry = make_registry();
597        registry.register(Arc::new(EchoTool));
598        let plugin = ProgramPlugin::from_yaml(
599            "program-pack",
600            r#"
601programs:
602  - name: asset_echo
603    description: Echo from a YAML asset
604    parameters:
605      - name: message
606        description: Message to echo
607        required: true
608    steps:
609      - tool_name: echo
610        label: echo_message
611        args:
612          message: "{{message}}"
613"#,
614        )
615        .unwrap()
616        .without_builtin_programs();
617
618        plugin.load(&registry, &PluginContext::new()).unwrap();
619
620        let result = registry
621            .execute_with_context(
622                "program",
623                &serde_json::json!({
624                    "name": "asset_echo",
625                    "inputs": { "message": "from asset" }
626                }),
627                &ToolContext::new(PathBuf::from("/tmp")),
628            )
629            .await
630            .unwrap();
631
632        assert_eq!(result.exit_code, 1);
633        assert!(result.output.contains("type parameter is required"));
634    }
635
636    #[test]
637    fn program_plugin_rejects_empty_catalog() {
638        let registry = make_registry();
639        let plugin = ProgramPlugin::new("empty-program-pack");
640
641        let err = plugin.load(&registry, &PluginContext::new()).unwrap_err();
642
643        assert!(err.to_string().contains("has no program templates"));
644    }
645
646    #[test]
647    fn program_plugin_rejects_invalid_template_assets() {
648        let registry = make_registry();
649        let plugin =
650            ProgramPlugin::new("bad-program-pack").with_template(crate::program::ProgramTemplate {
651                name: "bad-template".to_string(),
652                description: "Bad template".to_string(),
653                parameters: vec![],
654                steps: vec![crate::program::ProgramStepTemplate {
655                    tool_name: "grep".to_string(),
656                    args: serde_json::json!({ "pattern": "{{missing}}" }),
657                    label: None,
658                }],
659            });
660
661        let err = plugin.load(&registry, &PluginContext::new()).unwrap_err();
662
663        assert!(err.to_string().contains("unknown program parameter"));
664    }
665
666    #[test]
667    fn skill_plugin_no_tools_and_injects_skills() {
668        use crate::skills::SkillRegistry;
669
670        let skill_md = r#"---
671name: test-skill
672description: Test skill
673allowed-tools: "bash(*)"
674kind: instruction
675---
676Test instruction."#;
677
678        let mut mgr = PluginManager::new();
679        mgr.register(SkillPlugin::new("test-plugin").with_skill(skill_md));
680
681        let registry = make_registry();
682        let skill_reg = Arc::new(SkillRegistry::new());
683        let ctx = PluginContext::new().with_skill_registry(Arc::clone(&skill_reg));
684
685        mgr.load_all(&registry, &ctx);
686
687        // No tools registered
688        assert!(registry.get("test-plugin").is_none());
689        // Skill registered
690        assert!(skill_reg.get("test-skill").is_some());
691    }
692
693    #[test]
694    fn skill_plugin_with_skills_builder() {
695        let skill1 = "---\nname: s1\ndescription: d1\nkind: instruction\n---\nContent 1";
696        let skill2 = "---\nname: s2\ndescription: d2\nkind: instruction\n---\nContent 2";
697
698        let plugin = SkillPlugin::new("multi").with_skills([skill1, skill2]);
699        assert_eq!(plugin.skills().len(), 2);
700    }
701
702    #[test]
703    fn plugin_names() {
704        let mut mgr = PluginManager::new();
705        mgr.register(SkillPlugin::new("a"));
706        mgr.register(SkillPlugin::new("b"));
707        let names = mgr.plugin_names();
708        assert!(names.contains(&"a"));
709        assert!(names.contains(&"b"));
710    }
711}