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//! # Architecture
8//!
9//! ```text
10//! SessionOptions::plugins  ──►  PluginManager::load_all(registry, ctx)
11//!                                     │
12//!                         ┌───────────┴────────────┐
13//!                         ▼                        ▼
14//!              AgenticSearchPlugin       AgenticParsePlugin
15//!              (registers agentic_search) (registers agentic_parse)
16//! ```
17//!
18//! # Usage
19//!
20//! ```rust,ignore
21//! use a3s_code_core::{SessionOptions, AgenticSearchPlugin, AgenticParsePlugin};
22//!
23//! let opts = SessionOptions::new()
24//!     .with_plugin(AgenticSearchPlugin::new())
25//!     .with_plugin(AgenticParsePlugin::new());
26//! ```
27
28use crate::skills::Skill;
29use crate::tools::ToolRegistry;
30use anyhow::Result;
31use std::sync::Arc;
32
33// ============================================================================
34// Plugin context — passed to every plugin on load
35// ============================================================================
36
37/// Runtime context provided to plugins when they are loaded.
38///
39/// Gives plugins access to dependencies they may need (LLM client,
40/// skill registry, document parser registry, etc.) without coupling the
41/// `Plugin` trait to specific concrete types.
42#[derive(Clone)]
43pub struct PluginContext {
44    /// LLM client — required by tools that do LLM inference (e.g. agentic_parse).
45    pub llm: Option<Arc<dyn crate::llm::LlmClient>>,
46    /// Document parser registry — required by document-aware tools.
47    pub document_parsers: Option<Arc<crate::document_parser::DocumentParserRegistry>>,
48    /// Skill registry — plugins may register companion skills here.
49    pub skill_registry: Option<Arc<crate::skills::SkillRegistry>>,
50}
51
52impl PluginContext {
53    pub fn new() -> Self {
54        Self {
55            llm: None,
56            document_parsers: None,
57            skill_registry: None,
58        }
59    }
60
61    pub fn with_llm(mut self, llm: Arc<dyn crate::llm::LlmClient>) -> Self {
62        self.llm = Some(llm);
63        self
64    }
65
66    pub fn with_document_parsers(
67        mut self,
68        registry: Arc<crate::document_parser::DocumentParserRegistry>,
69    ) -> Self {
70        self.document_parsers = Some(registry);
71        self
72    }
73
74    pub fn with_skill_registry(mut self, registry: Arc<crate::skills::SkillRegistry>) -> Self {
75        self.skill_registry = Some(registry);
76        self
77    }
78}
79
80impl Default for PluginContext {
81    fn default() -> Self {
82        Self::new()
83    }
84}
85
86// ============================================================================
87// Plugin trait
88// ============================================================================
89
90/// Unified interface for all A3S Code plugins.
91///
92/// A plugin is a self-contained unit that registers one or more tools into a
93/// `ToolRegistry` when loaded and removes them when unloaded.  This gives the
94/// host application a single consistent API for managing optional capabilities.
95///
96/// # Implementing a plugin
97///
98/// ```rust,ignore
99/// use a3s_code_core::plugin::{Plugin, PluginContext};
100/// use a3s_code_core::tools::ToolRegistry;
101/// use anyhow::Result;
102/// use std::sync::Arc;
103///
104/// struct MyPlugin;
105///
106/// impl Plugin for MyPlugin {
107///     fn name(&self) -> &str { "my-plugin" }
108///     fn version(&self) -> &str { "0.1.0" }
109///     fn tool_names(&self) -> &[&str] { &["my_tool"] }
110///     fn load(&self, registry: &Arc<ToolRegistry>, _ctx: &PluginContext) -> Result<()> {
111///         Ok(())
112///     }
113/// }
114/// ```
115pub trait Plugin: Send + Sync {
116    /// Unique plugin identifier (kebab-case, e.g. `"agentic-search"`).
117    fn name(&self) -> &str;
118
119    /// Plugin version string (semver, e.g. `"1.0.0"`).
120    fn version(&self) -> &str;
121
122    /// Names of all tools this plugin registers.
123    ///
124    /// Used by `PluginManager::unload` to remove the correct tools.
125    fn tool_names(&self) -> &[&str];
126
127    /// Register this plugin's tools into `registry`.
128    ///
129    /// Called once when the plugin is mounted onto a session.
130    fn load(&self, registry: &Arc<ToolRegistry>, ctx: &PluginContext) -> Result<()>;
131
132    /// Remove this plugin's tools from `registry`.
133    ///
134    /// The default implementation unregisters every tool listed in
135    /// `tool_names()`.  Override only if you need custom cleanup.
136    fn unload(&self, registry: &Arc<ToolRegistry>) {
137        for name in self.tool_names() {
138            registry.unregister(name);
139        }
140    }
141
142    /// Human-readable description shown in plugin listings.
143    fn description(&self) -> &str {
144        ""
145    }
146
147    /// Skills bundled with this plugin.
148    ///
149    /// When the plugin is loaded successfully, each skill returned here is
150    /// registered into `PluginContext::skill_registry` (if one is provided).
151    /// This allows the skill to appear in the system prompt and be matched
152    /// against user requests automatically — no manual skill configuration
153    /// is needed by the caller.
154    ///
155    /// Override to return plugin-specific skills. The default returns an
156    /// empty list (no companion skills).
157    fn skills(&self) -> Vec<Arc<Skill>> {
158        vec![]
159    }
160}
161
162// ============================================================================
163// PluginManager
164// ============================================================================
165
166/// Manages the lifecycle of all loaded plugins for a session.
167///
168/// Each session owns its own `PluginManager`; plugins are not shared across
169/// sessions so that sessions can have different capability sets.
170#[derive(Default)]
171pub struct PluginManager {
172    plugins: Vec<Arc<dyn Plugin>>,
173}
174
175impl PluginManager {
176    pub fn new() -> Self {
177        Self::default()
178    }
179
180    /// Register a plugin. Does not load it yet.
181    pub fn register(&mut self, plugin: impl Plugin + 'static) {
182        self.plugins.push(Arc::new(plugin));
183    }
184
185    /// Register a pre-boxed plugin.
186    pub fn register_arc(&mut self, plugin: Arc<dyn Plugin>) {
187        self.plugins.push(plugin);
188    }
189
190    /// Load all registered plugins into `registry`.
191    ///
192    /// Plugins are loaded in registration order.  If a plugin fails to load,
193    /// the error is logged and loading continues for the remaining plugins.
194    ///
195    /// On a successful load, the plugin's companion skills (from [`Plugin::skills`])
196    /// are registered into `ctx.skill_registry` when one is provided.
197    pub fn load_all(&self, registry: &Arc<ToolRegistry>, ctx: &PluginContext) {
198        for plugin in &self.plugins {
199            tracing::info!("Loading plugin '{}' v{}", plugin.name(), plugin.version());
200            match plugin.load(registry, ctx) {
201                Ok(()) => {
202                    if let Some(ref skill_reg) = ctx.skill_registry {
203                        for skill in plugin.skills() {
204                            tracing::debug!(
205                                "Plugin '{}' registered skill '{}'",
206                                plugin.name(),
207                                skill.name
208                            );
209                            skill_reg.register_unchecked(skill);
210                        }
211                    }
212                }
213                Err(e) => {
214                    tracing::error!("Plugin '{}' failed to load: {}", plugin.name(), e);
215                }
216            }
217        }
218    }
219
220    /// Unload a single plugin by name.
221    ///
222    /// Removes the plugin's tools from the registry and deregisters the plugin.
223    pub fn unload(&mut self, name: &str, registry: &Arc<ToolRegistry>) {
224        if let Some(pos) = self.plugins.iter().position(|p| p.name() == name) {
225            let plugin = self.plugins.remove(pos);
226            tracing::info!("Unloading plugin '{}'", plugin.name());
227            plugin.unload(registry);
228        }
229    }
230
231    /// Unload all plugins.
232    pub fn unload_all(&mut self, registry: &Arc<ToolRegistry>) {
233        for plugin in self.plugins.drain(..).rev() {
234            tracing::info!("Unloading plugin '{}'", plugin.name());
235            plugin.unload(registry);
236        }
237    }
238
239    /// Returns `true` if a plugin with `name` is registered.
240    pub fn is_loaded(&self, name: &str) -> bool {
241        self.plugins.iter().any(|p| p.name() == name)
242    }
243
244    /// Number of registered plugins.
245    pub fn len(&self) -> usize {
246        self.plugins.len()
247    }
248
249    /// Returns `true` if no plugins are registered.
250    pub fn is_empty(&self) -> bool {
251        self.plugins.is_empty()
252    }
253
254    /// List all registered plugin names.
255    pub fn plugin_names(&self) -> Vec<&str> {
256        self.plugins.iter().map(|p| p.name()).collect()
257    }
258}
259
260// ============================================================================
261// Built-in plugin implementations
262// ============================================================================
263
264// Skill content embedded at compile time.  Paths are relative to this file
265// (core/src/plugin.rs → core/skills/).
266const AGENTIC_SEARCH_SKILL_MD: &str = include_str!("../skills/agentic-search.md");
267const AGENTIC_PARSE_SKILL_MD: &str = include_str!("../skills/agentic-parse.md");
268
269fn parse_embedded_skill(content: &str, name: &str) -> Option<Arc<Skill>> {
270    match Skill::parse(content) {
271        Some(skill) => Some(Arc::new(skill)),
272        None => {
273            tracing::warn!(
274                "Failed to parse embedded skill '{}' — skill will not be registered",
275                name
276            );
277            None
278        }
279    }
280}
281
282// ============================================================================
283// SkillPlugin — skill-only plugin (no tools)
284// ============================================================================
285
286/// A skill-only plugin that injects custom skills into the session's skill
287/// registry without registering any tools.
288///
289/// This is the primary way to add custom LLM guidance from Python or Node.js
290/// without writing Rust. Provide skill YAML/markdown content strings and they
291/// will appear in the system prompt automatically.
292///
293/// # Example
294///
295/// ```rust,ignore
296/// let plugin = SkillPlugin::new("my-plugin")
297///     .with_skill(r#"---
298/// name: my-skill
299/// description: Use bash when running shell commands
300/// allowed-tools: "bash(*)"
301/// kind: instruction
302/// ---
303/// Always explain what command you're about to run before executing it."#);
304///
305/// let opts = SessionOptions::new().with_plugin(plugin);
306/// ```
307pub struct SkillPlugin {
308    plugin_name: String,
309    plugin_version: String,
310    skill_contents: Vec<String>,
311}
312
313impl SkillPlugin {
314    pub fn new(name: impl Into<String>) -> Self {
315        Self {
316            plugin_name: name.into(),
317            plugin_version: "1.0.0".into(),
318            skill_contents: vec![],
319        }
320    }
321
322    pub fn with_skill(mut self, content: impl Into<String>) -> Self {
323        self.skill_contents.push(content.into());
324        self
325    }
326
327    pub fn with_skills(mut self, contents: impl IntoIterator<Item = impl Into<String>>) -> Self {
328        self.skill_contents
329            .extend(contents.into_iter().map(|s| s.into()));
330        self
331    }
332}
333
334impl Plugin for SkillPlugin {
335    fn name(&self) -> &str {
336        &self.plugin_name
337    }
338
339    fn version(&self) -> &str {
340        &self.plugin_version
341    }
342
343    fn tool_names(&self) -> &[&str] {
344        &[]
345    }
346
347    fn load(&self, _registry: &Arc<ToolRegistry>, _ctx: &PluginContext) -> Result<()> {
348        Ok(())
349    }
350
351    fn skills(&self) -> Vec<Arc<Skill>> {
352        self.skill_contents
353            .iter()
354            .filter_map(|content| Skill::parse(content).map(Arc::new))
355            .collect()
356    }
357}
358
359/// Plugin that mounts the `agentic_search` tool.
360///
361/// `agentic_search` is a multi-phase semantic code search tool. It reads
362/// files through `ToolContext::document_parsers` when available, falling
363/// back to plain-text for unknown formats.
364pub struct AgenticSearchPlugin;
365
366impl AgenticSearchPlugin {
367    pub fn new() -> Self {
368        Self
369    }
370}
371
372impl Default for AgenticSearchPlugin {
373    fn default() -> Self {
374        Self::new()
375    }
376}
377
378impl Plugin for AgenticSearchPlugin {
379    fn name(&self) -> &str {
380        "agentic-search"
381    }
382
383    fn version(&self) -> &str {
384        env!("CARGO_PKG_VERSION")
385    }
386
387    fn tool_names(&self) -> &[&str] {
388        &["agentic_search"]
389    }
390
391    fn description(&self) -> &str {
392        "Multi-phase semantic code search with IDF-weighted relevance scoring \
393         and Monte Carlo deep-search mode."
394    }
395
396    fn load(&self, registry: &Arc<ToolRegistry>, _ctx: &PluginContext) -> Result<()> {
397        use crate::tools::AgenticSearchTool;
398        registry.register(Arc::new(AgenticSearchTool::new()));
399        tracing::debug!("agentic_search tool registered");
400        Ok(())
401    }
402
403    fn skills(&self) -> Vec<Arc<Skill>> {
404        parse_embedded_skill(AGENTIC_SEARCH_SKILL_MD, "agentic-search")
405            .into_iter()
406            .collect()
407    }
408}
409
410/// Plugin that mounts the `agentic_parse` tool.
411///
412/// `agentic_parse` uses the `DocumentParserRegistry` for binary format
413/// decoding and an LLM for semantic extraction / QA.
414///
415/// Requires an LLM client in `PluginContext`. If none is provided the plugin
416/// still loads but logs a warning; the tool will return an error at runtime.
417pub struct AgenticParsePlugin;
418
419impl AgenticParsePlugin {
420    pub fn new() -> Self {
421        Self
422    }
423}
424
425impl Default for AgenticParsePlugin {
426    fn default() -> Self {
427        Self::new()
428    }
429}
430
431impl Plugin for AgenticParsePlugin {
432    fn name(&self) -> &str {
433        "agentic-parse"
434    }
435
436    fn version(&self) -> &str {
437        env!("CARGO_PKG_VERSION")
438    }
439
440    fn tool_names(&self) -> &[&str] {
441        &["agentic_parse"]
442    }
443
444    fn description(&self) -> &str {
445        "LLM-enhanced document parsing with structural extraction, \
446         5 parse strategies, and semantic QA."
447    }
448
449    fn load(&self, registry: &Arc<ToolRegistry>, ctx: &PluginContext) -> Result<()> {
450        use crate::tools::AgenticParseTool;
451        let llm = ctx.llm.clone().ok_or_else(|| {
452            anyhow::anyhow!("agentic-parse plugin requires an LLM client in PluginContext")
453        })?;
454        registry.register(Arc::new(AgenticParseTool::new(llm)));
455        tracing::debug!("agentic_parse tool registered");
456        Ok(())
457    }
458
459    fn skills(&self) -> Vec<Arc<Skill>> {
460        parse_embedded_skill(AGENTIC_PARSE_SKILL_MD, "agentic-parse")
461            .into_iter()
462            .collect()
463    }
464}
465
466// ============================================================================
467// Tests
468// ============================================================================
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use crate::tools::ToolRegistry;
474    use std::path::PathBuf;
475
476    fn make_registry() -> Arc<ToolRegistry> {
477        Arc::new(ToolRegistry::new(PathBuf::from("/tmp")))
478    }
479
480    #[test]
481    fn plugin_manager_register_and_query() {
482        let mut mgr = PluginManager::new();
483        assert!(mgr.is_empty());
484        mgr.register(AgenticSearchPlugin::new());
485        assert_eq!(mgr.len(), 1);
486        assert!(mgr.is_loaded("agentic-search"));
487        assert!(!mgr.is_loaded("agentic-parse"));
488    }
489
490    #[test]
491    fn plugin_manager_load_all() {
492        let mut mgr = PluginManager::new();
493        mgr.register(AgenticSearchPlugin::new());
494        let registry = make_registry();
495        let ctx = PluginContext::new();
496        mgr.load_all(&registry, &ctx);
497        assert!(registry.get("agentic_search").is_some());
498    }
499
500    #[test]
501    fn plugin_manager_unload() {
502        let mut mgr = PluginManager::new();
503        mgr.register(AgenticSearchPlugin::new());
504        let registry = make_registry();
505        let ctx = PluginContext::new();
506        mgr.load_all(&registry, &ctx);
507        assert!(registry.get("agentic_search").is_some());
508        mgr.unload("agentic-search", &registry);
509        assert!(registry.get("agentic_search").is_none());
510        assert!(!mgr.is_loaded("agentic-search"));
511    }
512
513    #[test]
514    fn plugin_manager_unload_all() {
515        let mut mgr = PluginManager::new();
516        mgr.register(AgenticSearchPlugin::new());
517        let registry = make_registry();
518        let ctx = PluginContext::new();
519        mgr.load_all(&registry, &ctx);
520        mgr.unload_all(&registry);
521        assert!(mgr.is_empty());
522        assert!(registry.get("agentic_search").is_none());
523    }
524
525    #[test]
526    fn agentic_search_plugin_metadata() {
527        let p = AgenticSearchPlugin::new();
528        assert_eq!(p.name(), "agentic-search");
529        assert_eq!(p.tool_names(), &["agentic_search"]);
530        assert!(!p.description().is_empty());
531    }
532
533    #[test]
534    fn agentic_search_plugin_provides_skill() {
535        let p = AgenticSearchPlugin::new();
536        let skills = p.skills();
537        assert_eq!(skills.len(), 1);
538        assert_eq!(skills[0].name, "agentic-search");
539        assert!(skills[0].is_tool_allowed("agentic_search"));
540    }
541
542    #[test]
543    fn agentic_parse_plugin_provides_skill() {
544        let p = AgenticParsePlugin::new();
545        let skills = p.skills();
546        assert_eq!(skills.len(), 1);
547        assert_eq!(skills[0].name, "agentic-parse");
548        assert!(skills[0].is_tool_allowed("agentic_parse"));
549    }
550
551    #[test]
552    fn plugin_skills_registered_on_load_all() {
553        use crate::skills::SkillRegistry;
554
555        let mut mgr = PluginManager::new();
556        mgr.register(AgenticSearchPlugin::new());
557
558        let registry = make_registry();
559        let skill_reg = Arc::new(SkillRegistry::new());
560        let ctx = PluginContext::new().with_skill_registry(Arc::clone(&skill_reg));
561
562        mgr.load_all(&registry, &ctx);
563
564        // Tool registered
565        assert!(registry.get("agentic_search").is_some());
566        // Skill registered
567        assert!(skill_reg.get("agentic-search").is_some());
568    }
569
570    #[test]
571    fn plugin_skills_not_registered_when_no_skill_registry_in_ctx() {
572        let mut mgr = PluginManager::new();
573        mgr.register(AgenticSearchPlugin::new());
574
575        let registry = make_registry();
576        // No skill_registry in ctx
577        let ctx = PluginContext::new();
578        mgr.load_all(&registry, &ctx);
579
580        // Tool still registered
581        assert!(registry.get("agentic_search").is_some());
582        // No crash — skill registry absence is silently tolerated
583    }
584
585    #[test]
586    fn failed_plugin_skills_not_registered() {
587        use crate::skills::SkillRegistry;
588
589        // AgenticParsePlugin requires an LLM client; loading without one fails
590        let mut mgr = PluginManager::new();
591        mgr.register(AgenticParsePlugin::new());
592
593        let registry = make_registry();
594        let skill_reg = Arc::new(SkillRegistry::new());
595        let ctx = PluginContext::new().with_skill_registry(Arc::clone(&skill_reg));
596        // No LLM in ctx → load() returns Err → skills() must NOT be called
597
598        mgr.load_all(&registry, &ctx);
599
600        assert!(registry.get("agentic_parse").is_none());
601        assert!(skill_reg.get("agentic-parse").is_none());
602    }
603
604    #[test]
605    fn agentic_parse_plugin_fails_without_llm() {
606        let p = AgenticParsePlugin::new();
607        let registry = make_registry();
608        let ctx = PluginContext::new(); // no LLM
609        let result = p.load(&registry, &ctx);
610        assert!(result.is_err());
611        assert!(result.unwrap_err().to_string().contains("LLM client"));
612    }
613
614    #[test]
615    fn skill_plugin_no_tools_and_injects_skills() {
616        use crate::skills::SkillRegistry;
617
618        let skill_md = r#"---
619name: test-skill
620description: Test skill
621allowed-tools: "bash(*)"
622kind: instruction
623---
624Test instruction."#;
625
626        let mut mgr = PluginManager::new();
627        mgr.register(SkillPlugin::new("test-plugin").with_skill(skill_md));
628
629        let registry = make_registry();
630        let skill_reg = Arc::new(SkillRegistry::new());
631        let ctx = PluginContext::new().with_skill_registry(Arc::clone(&skill_reg));
632
633        mgr.load_all(&registry, &ctx);
634
635        // No tools registered
636        assert!(registry.get("test-plugin").is_none());
637        // Skill registered
638        assert!(skill_reg.get("test-skill").is_some());
639    }
640
641    #[test]
642    fn skill_plugin_with_skills_builder() {
643        let skill1 = "---\nname: s1\ndescription: d1\nkind: instruction\n---\nContent 1";
644        let skill2 = "---\nname: s2\ndescription: d2\nkind: instruction\n---\nContent 2";
645
646        let plugin = SkillPlugin::new("multi").with_skills([skill1, skill2]);
647        assert_eq!(plugin.skills().len(), 2);
648    }
649
650    #[test]
651    fn plugin_names() {
652        let mut mgr = PluginManager::new();
653        mgr.register(AgenticSearchPlugin::new());
654        mgr.register(AgenticParsePlugin::new());
655        let names = mgr.plugin_names();
656        assert!(names.contains(&"agentic-search"));
657        assert!(names.contains(&"agentic-parse"));
658    }
659}