use crate::skills::Skill;
use crate::tools::{register_program, register_program_with_catalog, ToolRegistry};
use anyhow::{bail, Result};
use std::sync::Arc;
#[derive(Clone)]
pub struct PluginContext {
pub llm: Option<Arc<dyn crate::llm::LlmClient>>,
pub skill_registry: Option<Arc<crate::skills::SkillRegistry>>,
}
impl PluginContext {
pub fn new() -> Self {
Self {
llm: None,
skill_registry: None,
}
}
pub fn with_llm(mut self, llm: Arc<dyn crate::llm::LlmClient>) -> Self {
self.llm = Some(llm);
self
}
pub fn with_skill_registry(mut self, registry: Arc<crate::skills::SkillRegistry>) -> Self {
self.skill_registry = Some(registry);
self
}
}
impl Default for PluginContext {
fn default() -> Self {
Self::new()
}
}
pub trait Plugin: Send + Sync {
fn name(&self) -> &str;
fn version(&self) -> &str;
fn tool_names(&self) -> &[&str];
fn load(&self, registry: &Arc<ToolRegistry>, ctx: &PluginContext) -> Result<()>;
fn unload(&self, registry: &Arc<ToolRegistry>) {
for name in self.tool_names() {
registry.unregister(name);
}
}
fn description(&self) -> &str {
""
}
fn skills(&self) -> Vec<Arc<Skill>> {
vec![]
}
}
#[derive(Default)]
pub struct PluginManager {
plugins: Vec<Arc<dyn Plugin>>,
}
impl PluginManager {
pub fn new() -> Self {
Self::default()
}
pub fn register(&mut self, plugin: impl Plugin + 'static) {
self.plugins.push(Arc::new(plugin));
}
pub fn register_arc(&mut self, plugin: Arc<dyn Plugin>) {
self.plugins.push(plugin);
}
pub fn load_all(&self, registry: &Arc<ToolRegistry>, ctx: &PluginContext) {
for plugin in &self.plugins {
tracing::info!("Loading plugin '{}' v{}", plugin.name(), plugin.version());
match plugin.load(registry, ctx) {
Ok(()) => {
if let Some(ref skill_reg) = ctx.skill_registry {
for skill in plugin.skills() {
tracing::debug!(
"Plugin '{}' registered skill '{}'",
plugin.name(),
skill.name
);
skill_reg.register_unchecked(skill);
}
}
}
Err(e) => {
tracing::error!("Plugin '{}' failed to load: {}", plugin.name(), e);
}
}
}
}
pub fn unload(&mut self, name: &str, registry: &Arc<ToolRegistry>) {
if let Some(pos) = self.plugins.iter().position(|p| p.name() == name) {
let plugin = self.plugins.remove(pos);
tracing::info!("Unloading plugin '{}'", plugin.name());
plugin.unload(registry);
}
}
pub fn unload_all(&mut self, registry: &Arc<ToolRegistry>) {
for plugin in self.plugins.drain(..).rev() {
tracing::info!("Unloading plugin '{}'", plugin.name());
plugin.unload(registry);
}
}
pub fn is_loaded(&self, name: &str) -> bool {
self.plugins.iter().any(|p| p.name() == name)
}
pub fn len(&self) -> usize {
self.plugins.len()
}
pub fn is_empty(&self) -> bool {
self.plugins.is_empty()
}
pub fn plugin_names(&self) -> Vec<&str> {
self.plugins.iter().map(|p| p.name()).collect()
}
}
pub struct SkillPlugin {
plugin_name: String,
plugin_version: String,
skill_contents: Vec<String>,
}
impl SkillPlugin {
pub fn new(name: impl Into<String>) -> Self {
Self {
plugin_name: name.into(),
plugin_version: "1.0.0".into(),
skill_contents: vec![],
}
}
pub fn with_skill(mut self, content: impl Into<String>) -> Self {
self.skill_contents.push(content.into());
self
}
pub fn with_skills(mut self, contents: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.skill_contents
.extend(contents.into_iter().map(|s| s.into()));
self
}
}
pub struct ProgramPlugin {
plugin_name: String,
plugin_version: String,
templates: Vec<crate::program::ProgramTemplate>,
include_builtin_programs: bool,
}
impl ProgramPlugin {
pub fn new(name: impl Into<String>) -> Self {
Self {
plugin_name: name.into(),
plugin_version: "1.0.0".into(),
templates: Vec::new(),
include_builtin_programs: true,
}
}
pub fn with_version(mut self, version: impl Into<String>) -> Self {
self.plugin_version = version.into();
self
}
pub fn with_template(mut self, template: crate::program::ProgramTemplate) -> Self {
self.templates.push(template);
self
}
pub fn with_templates(
mut self,
templates: impl IntoIterator<Item = crate::program::ProgramTemplate>,
) -> Self {
self.templates.extend(templates);
self
}
pub fn without_builtin_programs(mut self) -> Self {
self.include_builtin_programs = false;
self
}
pub fn from_json(name: impl Into<String>, content: &str) -> Result<Self> {
let asset = serde_json::from_str::<ProgramTemplateAsset>(content)?;
Ok(Self::new(name).with_templates(asset.into_templates()))
}
pub fn from_yaml(name: impl Into<String>, content: &str) -> Result<Self> {
let asset = serde_yaml::from_str::<ProgramTemplateAsset>(content)?;
Ok(Self::new(name).with_templates(asset.into_templates()))
}
}
impl Plugin for ProgramPlugin {
fn name(&self) -> &str {
&self.plugin_name
}
fn version(&self) -> &str {
&self.plugin_version
}
fn tool_names(&self) -> &[&str] {
&["program"]
}
fn load(&self, registry: &Arc<ToolRegistry>, _ctx: &PluginContext) -> Result<()> {
if self.templates.is_empty() {
bail!(
"ProgramPlugin '{}' has no program templates",
self.plugin_name
);
}
let mut catalog = if self.include_builtin_programs {
crate::program::ProgramCatalog::with_builtin_programs()
} else {
crate::program::ProgramCatalog::new()
};
for template in &self.templates {
catalog.try_register(template.clone())?;
}
register_program_with_catalog(registry, catalog);
Ok(())
}
fn unload(&self, registry: &Arc<ToolRegistry>) {
register_program(registry);
}
fn description(&self) -> &str {
"Registers programmatic tool calling templates"
}
}
#[derive(Debug, serde::Deserialize)]
#[serde(untagged)]
enum ProgramTemplateAsset {
Template(crate::program::ProgramTemplate),
Templates(Vec<crate::program::ProgramTemplate>),
Catalog {
programs: Vec<crate::program::ProgramTemplate>,
},
}
impl ProgramTemplateAsset {
fn into_templates(self) -> Vec<crate::program::ProgramTemplate> {
match self {
Self::Template(template) => vec![template],
Self::Templates(templates) => templates,
Self::Catalog { programs } => programs,
}
}
}
impl Plugin for SkillPlugin {
fn name(&self) -> &str {
&self.plugin_name
}
fn version(&self) -> &str {
&self.plugin_version
}
fn tool_names(&self) -> &[&str] {
&[]
}
fn load(&self, _registry: &Arc<ToolRegistry>, _ctx: &PluginContext) -> Result<()> {
Ok(())
}
fn skills(&self) -> Vec<Arc<Skill>> {
self.skill_contents
.iter()
.filter_map(|content| Skill::parse(content).map(Arc::new))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::{Tool, ToolContext, ToolOutput, ToolRegistry};
use async_trait::async_trait;
use std::path::PathBuf;
fn make_registry() -> Arc<ToolRegistry> {
Arc::new(ToolRegistry::new(PathBuf::from("/tmp")))
}
struct EchoTool;
#[async_trait]
impl Tool for EchoTool {
fn name(&self) -> &str {
"echo"
}
fn description(&self) -> &str {
"Echoes a message"
}
fn parameters(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"additionalProperties": false,
"properties": {
"message": { "type": "string" }
},
"required": ["message"]
})
}
async fn execute(
&self,
args: &serde_json::Value,
_ctx: &ToolContext,
) -> Result<ToolOutput> {
Ok(ToolOutput::success(
args["message"].as_str().unwrap_or_default(),
))
}
}
#[test]
fn plugin_manager_register_and_query() {
let mut mgr = PluginManager::new();
assert!(mgr.is_empty());
mgr.register(SkillPlugin::new("example"));
assert_eq!(mgr.len(), 1);
assert!(mgr.is_loaded("example"));
}
#[test]
fn plugin_manager_load_all() {
let mut mgr = PluginManager::new();
mgr.register(SkillPlugin::new("example"));
let registry = make_registry();
let ctx = PluginContext::new();
mgr.load_all(®istry, &ctx);
assert!(registry.get("example").is_none());
}
#[test]
fn plugin_manager_unload() {
let mut mgr = PluginManager::new();
mgr.register(SkillPlugin::new("example"));
let registry = make_registry();
let ctx = PluginContext::new();
mgr.load_all(®istry, &ctx);
mgr.unload("example", ®istry);
assert!(!mgr.is_loaded("example"));
}
#[test]
fn plugin_manager_unload_all() {
let mut mgr = PluginManager::new();
mgr.register(SkillPlugin::new("example"));
let registry = make_registry();
let ctx = PluginContext::new();
mgr.load_all(®istry, &ctx);
mgr.unload_all(®istry);
assert!(mgr.is_empty());
}
#[test]
fn plugin_skills_registered_on_load_all() {
use crate::skills::SkillRegistry;
let mut mgr = PluginManager::new();
mgr.register(SkillPlugin::new("test-plugin").with_skill(
r#"---
name: test-skill
description: Test skill
allowed-tools: "read(*)"
kind: instruction
---
Read carefully."#,
));
let registry = make_registry();
let skill_reg = Arc::new(SkillRegistry::new());
let ctx = PluginContext::new().with_skill_registry(Arc::clone(&skill_reg));
mgr.load_all(®istry, &ctx);
assert!(skill_reg.get("test-skill").is_some());
}
#[test]
fn plugin_skills_not_registered_when_no_skill_registry_in_ctx() {
let mut mgr = PluginManager::new();
mgr.register(SkillPlugin::new("test-plugin"));
let registry = make_registry();
let ctx = PluginContext::new();
mgr.load_all(®istry, &ctx);
}
#[tokio::test]
async fn program_plugin_loads_template_catalog_without_reenabling_named_programs() {
let registry = make_registry();
registry.register(Arc::new(EchoTool));
let plugin = ProgramPlugin::new("program-pack").with_template(
crate::program::ProgramTemplate::new("custom_echo", "Run a custom echo")
.with_parameter(crate::program::ProgramParameter::required(
"message",
"Message to echo",
))
.with_step(
crate::program::ProgramStepTemplate::new(
"echo",
serde_json::json!({ "message": "{{message}}" }),
)
.with_label("echo_message"),
),
);
plugin.load(®istry, &PluginContext::new()).unwrap();
let result = registry
.execute_with_context(
"program",
&serde_json::json!({
"name": "custom_echo",
"inputs": { "message": "hello" }
}),
&ToolContext::new(PathBuf::from("/tmp")),
)
.await
.unwrap();
assert_eq!(result.exit_code, 1);
assert!(result.output.contains("type parameter is required"));
}
#[tokio::test]
async fn program_plugin_can_load_templates_from_yaml_asset_without_named_execution() {
let registry = make_registry();
registry.register(Arc::new(EchoTool));
let plugin = ProgramPlugin::from_yaml(
"program-pack",
r#"
programs:
- name: asset_echo
description: Echo from a YAML asset
parameters:
- name: message
description: Message to echo
required: true
steps:
- tool_name: echo
label: echo_message
args:
message: "{{message}}"
"#,
)
.unwrap()
.without_builtin_programs();
plugin.load(®istry, &PluginContext::new()).unwrap();
let result = registry
.execute_with_context(
"program",
&serde_json::json!({
"name": "asset_echo",
"inputs": { "message": "from asset" }
}),
&ToolContext::new(PathBuf::from("/tmp")),
)
.await
.unwrap();
assert_eq!(result.exit_code, 1);
assert!(result.output.contains("type parameter is required"));
}
#[test]
fn program_plugin_rejects_empty_catalog() {
let registry = make_registry();
let plugin = ProgramPlugin::new("empty-program-pack");
let err = plugin.load(®istry, &PluginContext::new()).unwrap_err();
assert!(err.to_string().contains("has no program templates"));
}
#[test]
fn program_plugin_rejects_invalid_template_assets() {
let registry = make_registry();
let plugin =
ProgramPlugin::new("bad-program-pack").with_template(crate::program::ProgramTemplate {
name: "bad-template".to_string(),
description: "Bad template".to_string(),
parameters: vec![],
steps: vec![crate::program::ProgramStepTemplate {
tool_name: "grep".to_string(),
args: serde_json::json!({ "pattern": "{{missing}}" }),
label: None,
}],
});
let err = plugin.load(®istry, &PluginContext::new()).unwrap_err();
assert!(err.to_string().contains("unknown program parameter"));
}
#[test]
fn skill_plugin_no_tools_and_injects_skills() {
use crate::skills::SkillRegistry;
let skill_md = r#"---
name: test-skill
description: Test skill
allowed-tools: "bash(*)"
kind: instruction
---
Test instruction."#;
let mut mgr = PluginManager::new();
mgr.register(SkillPlugin::new("test-plugin").with_skill(skill_md));
let registry = make_registry();
let skill_reg = Arc::new(SkillRegistry::new());
let ctx = PluginContext::new().with_skill_registry(Arc::clone(&skill_reg));
mgr.load_all(®istry, &ctx);
assert!(registry.get("test-plugin").is_none());
assert!(skill_reg.get("test-skill").is_some());
}
#[test]
fn skill_plugin_with_skills_builder() {
let skill1 = "---\nname: s1\ndescription: d1\nkind: instruction\n---\nContent 1";
let skill2 = "---\nname: s2\ndescription: d2\nkind: instruction\n---\nContent 2";
let plugin = SkillPlugin::new("multi").with_skills([skill1, skill2]);
assert_eq!(plugin.skills().len(), 2);
}
#[test]
fn plugin_names() {
let mut mgr = PluginManager::new();
mgr.register(SkillPlugin::new("a"));
mgr.register(SkillPlugin::new("b"));
let names = mgr.plugin_names();
assert!(names.contains(&"a"));
assert!(names.contains(&"b"));
}
}