1use crate::skills::Skill;
17use crate::tools::ToolRegistry;
18use anyhow::Result;
19use std::sync::Arc;
20
21#[derive(Clone)]
31pub struct PluginContext {
32 pub llm: Option<Arc<dyn crate::llm::LlmClient>>,
34 pub document_parsers: Option<Arc<crate::document_parser::DocumentParserRegistry>>,
37 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
75pub trait Plugin: Send + Sync {
105 fn name(&self) -> &str;
107
108 fn version(&self) -> &str;
110
111 fn tool_names(&self) -> &[&str];
115
116 fn load(&self, registry: &Arc<ToolRegistry>, ctx: &PluginContext) -> Result<()>;
120
121 fn unload(&self, registry: &Arc<ToolRegistry>) {
126 for name in self.tool_names() {
127 registry.unregister(name);
128 }
129 }
130
131 fn description(&self) -> &str {
133 ""
134 }
135
136 fn skills(&self) -> Vec<Arc<Skill>> {
147 vec![]
148 }
149}
150
151#[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 pub fn register(&mut self, plugin: impl Plugin + 'static) {
171 self.plugins.push(Arc::new(plugin));
172 }
173
174 pub fn register_arc(&mut self, plugin: Arc<dyn Plugin>) {
176 self.plugins.push(plugin);
177 }
178
179 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 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 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 pub fn is_loaded(&self, name: &str) -> bool {
230 self.plugins.iter().any(|p| p.name() == name)
231 }
232
233 pub fn len(&self) -> usize {
235 self.plugins.len()
236 }
237
238 pub fn is_empty(&self) -> bool {
240 self.plugins.is_empty()
241 }
242
243 pub fn plugin_names(&self) -> Vec<&str> {
245 self.plugins.iter().map(|p| p.name()).collect()
246 }
247}
248
249pub 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#[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(®istry, &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(®istry, &ctx);
366 mgr.unload("example", ®istry);
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(®istry, &ctx);
377 mgr.unload_all(®istry);
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(®istry, &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 let ctx = PluginContext::new();
412 mgr.load_all(®istry, &ctx);
413 }
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(®istry, &ctx);
436
437 assert!(registry.get("test-plugin").is_none());
439 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}