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>>,
36 pub skill_registry: Option<Arc<crate::skills::SkillRegistry>>,
38}
39
40impl PluginContext {
41 pub fn new() -> Self {
42 Self {
43 llm: None,
44 document_parsers: None,
45 skill_registry: None,
46 }
47 }
48
49 pub fn with_llm(mut self, llm: Arc<dyn crate::llm::LlmClient>) -> Self {
50 self.llm = Some(llm);
51 self
52 }
53
54 pub fn with_document_parsers(
55 mut self,
56 registry: Arc<crate::document_parser::DocumentParserRegistry>,
57 ) -> Self {
58 self.document_parsers = Some(registry);
59 self
60 }
61
62 pub fn with_skill_registry(mut self, registry: Arc<crate::skills::SkillRegistry>) -> Self {
63 self.skill_registry = Some(registry);
64 self
65 }
66}
67
68impl Default for PluginContext {
69 fn default() -> Self {
70 Self::new()
71 }
72}
73
74pub trait Plugin: Send + Sync {
104 fn name(&self) -> &str;
106
107 fn version(&self) -> &str;
109
110 fn tool_names(&self) -> &[&str];
114
115 fn load(&self, registry: &Arc<ToolRegistry>, ctx: &PluginContext) -> Result<()>;
119
120 fn unload(&self, registry: &Arc<ToolRegistry>) {
125 for name in self.tool_names() {
126 registry.unregister(name);
127 }
128 }
129
130 fn description(&self) -> &str {
132 ""
133 }
134
135 fn skills(&self) -> Vec<Arc<Skill>> {
146 vec![]
147 }
148}
149
150#[derive(Default)]
159pub struct PluginManager {
160 plugins: Vec<Arc<dyn Plugin>>,
161}
162
163impl PluginManager {
164 pub fn new() -> Self {
165 Self::default()
166 }
167
168 pub fn register(&mut self, plugin: impl Plugin + 'static) {
170 self.plugins.push(Arc::new(plugin));
171 }
172
173 pub fn register_arc(&mut self, plugin: Arc<dyn Plugin>) {
175 self.plugins.push(plugin);
176 }
177
178 pub fn load_all(&self, registry: &Arc<ToolRegistry>, ctx: &PluginContext) {
186 for plugin in &self.plugins {
187 tracing::info!("Loading plugin '{}' v{}", plugin.name(), plugin.version());
188 match plugin.load(registry, ctx) {
189 Ok(()) => {
190 if let Some(ref skill_reg) = ctx.skill_registry {
191 for skill in plugin.skills() {
192 tracing::debug!(
193 "Plugin '{}' registered skill '{}'",
194 plugin.name(),
195 skill.name
196 );
197 skill_reg.register_unchecked(skill);
198 }
199 }
200 }
201 Err(e) => {
202 tracing::error!("Plugin '{}' failed to load: {}", plugin.name(), e);
203 }
204 }
205 }
206 }
207
208 pub fn unload(&mut self, name: &str, registry: &Arc<ToolRegistry>) {
212 if let Some(pos) = self.plugins.iter().position(|p| p.name() == name) {
213 let plugin = self.plugins.remove(pos);
214 tracing::info!("Unloading plugin '{}'", plugin.name());
215 plugin.unload(registry);
216 }
217 }
218
219 pub fn unload_all(&mut self, registry: &Arc<ToolRegistry>) {
221 for plugin in self.plugins.drain(..).rev() {
222 tracing::info!("Unloading plugin '{}'", plugin.name());
223 plugin.unload(registry);
224 }
225 }
226
227 pub fn is_loaded(&self, name: &str) -> bool {
229 self.plugins.iter().any(|p| p.name() == name)
230 }
231
232 pub fn len(&self) -> usize {
234 self.plugins.len()
235 }
236
237 pub fn is_empty(&self) -> bool {
239 self.plugins.is_empty()
240 }
241
242 pub fn plugin_names(&self) -> Vec<&str> {
244 self.plugins.iter().map(|p| p.name()).collect()
245 }
246}
247
248pub struct SkillPlugin {
274 plugin_name: String,
275 plugin_version: String,
276 skill_contents: Vec<String>,
277}
278
279impl SkillPlugin {
280 pub fn new(name: impl Into<String>) -> Self {
281 Self {
282 plugin_name: name.into(),
283 plugin_version: "1.0.0".into(),
284 skill_contents: vec![],
285 }
286 }
287
288 pub fn with_skill(mut self, content: impl Into<String>) -> Self {
289 self.skill_contents.push(content.into());
290 self
291 }
292
293 pub fn with_skills(mut self, contents: impl IntoIterator<Item = impl Into<String>>) -> Self {
294 self.skill_contents
295 .extend(contents.into_iter().map(|s| s.into()));
296 self
297 }
298}
299
300impl Plugin for SkillPlugin {
301 fn name(&self) -> &str {
302 &self.plugin_name
303 }
304
305 fn version(&self) -> &str {
306 &self.plugin_version
307 }
308
309 fn tool_names(&self) -> &[&str] {
310 &[]
311 }
312
313 fn load(&self, _registry: &Arc<ToolRegistry>, _ctx: &PluginContext) -> Result<()> {
314 Ok(())
315 }
316
317 fn skills(&self) -> Vec<Arc<Skill>> {
318 self.skill_contents
319 .iter()
320 .filter_map(|content| Skill::parse(content).map(Arc::new))
321 .collect()
322 }
323}
324
325#[cfg(test)]
330mod tests {
331 use super::*;
332 use crate::tools::ToolRegistry;
333 use std::path::PathBuf;
334
335 fn make_registry() -> Arc<ToolRegistry> {
336 Arc::new(ToolRegistry::new(PathBuf::from("/tmp")))
337 }
338
339 #[test]
340 fn plugin_manager_register_and_query() {
341 let mut mgr = PluginManager::new();
342 assert!(mgr.is_empty());
343 mgr.register(SkillPlugin::new("example"));
344 assert_eq!(mgr.len(), 1);
345 assert!(mgr.is_loaded("example"));
346 }
347
348 #[test]
349 fn plugin_manager_load_all() {
350 let mut mgr = PluginManager::new();
351 mgr.register(SkillPlugin::new("example"));
352 let registry = make_registry();
353 let ctx = PluginContext::new();
354 mgr.load_all(®istry, &ctx);
355 assert!(registry.get("example").is_none());
356 }
357
358 #[test]
359 fn plugin_manager_unload() {
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(®istry, &ctx);
365 mgr.unload("example", ®istry);
366 assert!(!mgr.is_loaded("example"));
367 }
368
369 #[test]
370 fn plugin_manager_unload_all() {
371 let mut mgr = PluginManager::new();
372 mgr.register(SkillPlugin::new("example"));
373 let registry = make_registry();
374 let ctx = PluginContext::new();
375 mgr.load_all(®istry, &ctx);
376 mgr.unload_all(®istry);
377 assert!(mgr.is_empty());
378 }
379
380 #[test]
381 fn plugin_skills_registered_on_load_all() {
382 use crate::skills::SkillRegistry;
383
384 let mut mgr = PluginManager::new();
385 mgr.register(SkillPlugin::new("test-plugin").with_skill(
386 r#"---
387name: test-skill
388description: Test skill
389allowed-tools: "read(*)"
390kind: instruction
391---
392Read carefully."#,
393 ));
394
395 let registry = make_registry();
396 let skill_reg = Arc::new(SkillRegistry::new());
397 let ctx = PluginContext::new().with_skill_registry(Arc::clone(&skill_reg));
398
399 mgr.load_all(®istry, &ctx);
400 assert!(skill_reg.get("test-skill").is_some());
401 }
402
403 #[test]
404 fn plugin_skills_not_registered_when_no_skill_registry_in_ctx() {
405 let mut mgr = PluginManager::new();
406 mgr.register(SkillPlugin::new("test-plugin"));
407
408 let registry = make_registry();
409 let ctx = PluginContext::new();
411 mgr.load_all(®istry, &ctx);
412 }
414
415 #[test]
416 fn skill_plugin_no_tools_and_injects_skills() {
417 use crate::skills::SkillRegistry;
418
419 let skill_md = r#"---
420name: test-skill
421description: Test skill
422allowed-tools: "bash(*)"
423kind: instruction
424---
425Test instruction."#;
426
427 let mut mgr = PluginManager::new();
428 mgr.register(SkillPlugin::new("test-plugin").with_skill(skill_md));
429
430 let registry = make_registry();
431 let skill_reg = Arc::new(SkillRegistry::new());
432 let ctx = PluginContext::new().with_skill_registry(Arc::clone(&skill_reg));
433
434 mgr.load_all(®istry, &ctx);
435
436 assert!(registry.get("test-plugin").is_none());
438 assert!(skill_reg.get("test-skill").is_some());
440 }
441
442 #[test]
443 fn skill_plugin_with_skills_builder() {
444 let skill1 = "---\nname: s1\ndescription: d1\nkind: instruction\n---\nContent 1";
445 let skill2 = "---\nname: s2\ndescription: d2\nkind: instruction\n---\nContent 2";
446
447 let plugin = SkillPlugin::new("multi").with_skills([skill1, skill2]);
448 assert_eq!(plugin.skills().len(), 2);
449 }
450
451 #[test]
452 fn plugin_names() {
453 let mut mgr = PluginManager::new();
454 mgr.register(SkillPlugin::new("a"));
455 mgr.register(SkillPlugin::new("b"));
456 let names = mgr.plugin_names();
457 assert!(names.contains(&"a"));
458 assert!(names.contains(&"b"));
459 }
460}