use af_agent_session::ContentBlock;
use af_context::{ProfileRevisionId, RunId, SessionId, SubjectId, TenantId};
use std::collections::{BTreeMap, BTreeSet};
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use async_trait::async_trait;
use semver::{Version, VersionReq};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{
validate_json_schema, ContextContributor, PromptAuthority, PromptSection, Tool, ToolRegistry,
};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PluginManifest {
pub id: String,
pub version: String,
#[serde(default)]
pub dependencies: BTreeMap<String, String>,
#[serde(default)]
pub config_schema: Value,
#[serde(default)]
pub permissions: BTreeSet<PluginPermission>,
}
impl PluginManifest {
pub fn validate(&self) -> Result<(), PluginError> {
if self.id.trim().is_empty()
|| !self.id.chars().all(|ch| {
ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '.' | '-' | '_')
})
{
return Err(PluginError::InvalidManifest(format!(
"invalid plugin id {}",
self.id
)));
}
Version::parse(&self.version)
.map_err(|error| PluginError::InvalidManifest(format!("{}: {error}", self.id)))?;
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PluginPermission {
Prompt,
Context,
Tool,
Hook,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PluginDescriptor {
pub id: String,
pub version: String,
pub dependencies: BTreeMap<String, String>,
pub permissions: BTreeSet<PluginPermission>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginMountContext {
pub tenant_id: TenantId,
pub subject_id: SubjectId,
pub session_id: SessionId,
pub profile_revision_id: ProfileRevisionId,
}
#[derive(Debug, Clone, PartialEq)]
pub enum HookDecision {
Continue,
Deny {
reason: String,
},
WaitForInput {
kind: String,
payload: Value,
},
}
pub type ToolExecutionFuture<'a> = Pin<Box<dyn Future<Output = Result<Value, String>> + Send + 'a>>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StepContext {
pub request: af_context::RequestContext,
pub session_id: SessionId,
pub run_id: RunId,
pub turn: u32,
pub step: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PreStepDecision {
Enter,
Reject {
reason: String,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct ModelRequestPolicy {
pub model: String,
pub reasoning_effort: Option<af_llm::ReasoningEffort>,
pub temperature: f32,
pub max_tokens: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelFailure {
pub attempt: u32,
pub error: String,
pub retryable: bool,
pub attempts_remaining: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RequestErrorAction {
Default,
Retry,
Terminal,
}
#[derive(Debug, Clone, PartialEq)]
pub enum TurnStopDecision {
Stop,
Steer {
content: Vec<ContentBlock>,
},
}
#[async_trait]
pub trait Hook: Send + Sync {
async fn pre_step(&self, _context: &StepContext) -> PreStepDecision {
PreStepDecision::Enter
}
async fn request(&self, _context: &StepContext, _policy: &mut ModelRequestPolicy) {}
async fn request_error(
&self,
_context: &StepContext,
_failure: &ModelFailure,
) -> RequestErrorAction {
RequestErrorAction::Default
}
async fn turn_stopping(&self, _context: &StepContext) -> TurnStopDecision {
TurnStopDecision::Stop
}
async fn before_tool(
&self,
_context: &crate::ToolExecutionContext,
_tool: &str,
_arguments: &Value,
) -> HookDecision {
HookDecision::Continue
}
fn around_tool<'a>(
&'a self,
_context: &'a crate::ToolExecutionContext,
_tool: &'a str,
_arguments: &'a Value,
next: ToolExecutionFuture<'a>,
) -> ToolExecutionFuture<'a> {
next
}
async fn after_tool(
&self,
_context: &crate::ToolExecutionContext,
_tool: &str,
_arguments: &Value,
_result: &Value,
) -> Result<(), PluginError> {
Ok(())
}
}
#[derive(Default, Clone)]
struct PluginContributions {
prompts: BTreeMap<String, PromptSection>,
contexts: Vec<Arc<dyn ContextContributor>>,
tools: ToolRegistry,
hooks: Vec<Arc<dyn Hook>>,
}
impl PluginContributions {
fn merge(&mut self, additions: Self) -> Result<(), PluginError> {
let mut merged = self.clone();
for (id, prompt) in additions.prompts {
insert_once(&mut merged.prompts, id, prompt)?;
}
merged
.tools
.extend(&additions.tools)
.map_err(PluginError::Conflict)?;
merged.contexts.extend(additions.contexts);
merged.hooks.extend(additions.hooks);
*self = merged;
Ok(())
}
}
pub struct AgentRegistrar<'a> {
owner: &'a PluginManifest,
contributions: &'a mut PluginContributions,
}
impl AgentRegistrar<'_> {
pub fn prompt(
&mut self,
id: impl Into<String>,
prompt: impl Into<String>,
) -> Result<(), PluginError> {
require(self.owner, PluginPermission::Prompt)?;
let id = format!("plugin.{}.{}", self.owner.id, id.into());
insert_once(
&mut self.contributions.prompts,
id.clone(),
PromptSection {
id,
order: 400,
authority: PromptAuthority::Plugin,
source: self.owner.id.clone(),
version: self.owner.version.clone(),
content: prompt.into(),
},
)
}
pub fn tool(&mut self, tool: Arc<dyn Tool>) -> Result<(), PluginError> {
require(self.owner, PluginPermission::Tool)?;
if self.contributions.tools.contains(tool.name()) {
return Err(PluginError::Conflict(format!("tool {}", tool.name())));
}
self.contributions
.tools
.register(tool)
.map(|_| ())
.map_err(PluginError::Conflict)
}
pub fn context(&mut self, contributor: Arc<dyn ContextContributor>) -> Result<(), PluginError> {
require(self.owner, PluginPermission::Context)?;
self.contributions.contexts.push(contributor);
Ok(())
}
pub fn hook(&mut self, hook: Arc<dyn Hook>) -> Result<(), PluginError> {
require(self.owner, PluginPermission::Hook)?;
self.contributions.hooks.push(hook);
Ok(())
}
}
#[async_trait]
pub trait PluginLease: Send + Sync {
async fn unmount(&mut self) -> Result<(), PluginError>;
}
#[async_trait]
pub trait AgentPlugin: Send + Sync {
fn manifest(&self) -> &PluginManifest;
fn lease(&self, context: &PluginMountContext, config: &Value) -> Box<dyn PluginLease>;
async fn activate(
&self,
context: &PluginMountContext,
config: &Value,
registrar: &mut AgentRegistrar<'_>,
) -> Result<(), PluginError>;
}
pub struct MountedPlugins {
contributions: PluginContributions,
leases: Arc<LeaseSet>,
}
impl std::fmt::Debug for MountedPlugins {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("MountedPlugins")
.field(
"pending_leases",
&self
.leases
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.leases
.len(),
)
.finish_non_exhaustive()
}
}
impl MountedPlugins {
fn from_parts(contributions: PluginContributions, leases: Vec<Box<dyn PluginLease>>) -> Self {
Self {
contributions,
leases: Arc::new(LeaseSet {
state: std::sync::Mutex::new(LeaseState {
leases,
holders: 1,
closing: false,
}),
notify: tokio::sync::Notify::new(),
}),
}
}
pub fn prompts(&self) -> impl Iterator<Item = &PromptSection> {
self.contributions.prompts.values()
}
pub fn contexts(&self) -> &[Arc<dyn ContextContributor>] {
&self.contributions.contexts
}
pub fn tools(&self) -> &ToolRegistry {
&self.contributions.tools
}
pub fn hooks(&self) -> &[Arc<dyn Hook>] {
&self.contributions.hooks
}
pub async fn unmount(&mut self) -> Result<(), PluginError> {
let leases = Arc::clone(&self.leases);
{
leases
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.closing = true;
}
loop {
let notified = leases.notify.notified();
let pending = {
let mut state = leases
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
(state.holders == 1).then(|| std::mem::take(&mut state.leases))
};
if let Some(mut pending) = pending {
let mut failed = Vec::new();
let mut errors = Vec::new();
while let Some(mut lease) = pending.pop() {
if let Err(error) = lease.unmount().await {
errors.push(error.to_string());
failed.push(lease);
}
}
leases
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.leases = failed;
return if errors.is_empty() {
Ok(())
} else {
Err(PluginError::Mount(format!(
"plugin cleanup failed: {}",
errors.join("; ")
)))
};
}
notified.await;
}
}
}
impl Clone for MountedPlugins {
fn clone(&self) -> Self {
self.leases
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.holders += 1;
Self {
contributions: self.contributions.clone(),
leases: Arc::clone(&self.leases),
}
}
}
impl Drop for MountedPlugins {
fn drop(&mut self) {
let mut state = self
.leases
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.holders -= 1;
if state.closing && state.holders <= 1 {
self.leases.notify.notify_waiters();
}
}
}
struct LeaseSet {
state: std::sync::Mutex<LeaseState>,
notify: tokio::sync::Notify,
}
struct LeaseState {
leases: Vec<Box<dyn PluginLease>>,
holders: usize,
closing: bool,
}
#[derive(Default)]
pub struct PluginCatalog {
plugins: BTreeMap<(String, String), Arc<dyn AgentPlugin>>,
}
impl std::fmt::Debug for PluginCatalog {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("PluginCatalog")
.field("count", &self.plugins.len())
.finish()
}
}
impl PluginCatalog {
pub fn register(&mut self, plugin: Arc<dyn AgentPlugin>) -> Result<(), PluginError> {
plugin.manifest().validate()?;
let id = plugin.manifest().id.clone();
let version = plugin.manifest().version.clone();
if self.plugins.contains_key(&(id.clone(), version.clone())) {
return Err(PluginError::Conflict(format!("{id}@{version}")));
}
self.plugins.insert((id, version), plugin);
Ok(())
}
pub fn descriptors(&self) -> impl Iterator<Item = PluginDescriptor> + '_ {
self.plugins.values().map(|plugin| {
let manifest = plugin.manifest();
PluginDescriptor {
id: manifest.id.clone(),
version: manifest.version.clone(),
dependencies: manifest.dependencies.clone(),
permissions: manifest.permissions.clone(),
}
})
}
pub async fn mount(
&self,
context: &PluginMountContext,
references: &BTreeMap<String, (String, Value)>,
) -> Result<MountedPlugins, PluginError> {
let selected = self.select(references)?;
let configs = references
.iter()
.map(|(id, (_, config))| (id.clone(), config.clone()))
.collect();
let borrowed = selected
.iter()
.map(|plugin| plugin.as_ref())
.collect::<Vec<_>>();
mount_plugins(context, &borrowed, &configs).await
}
pub fn validate_references(
&self,
references: &BTreeMap<String, (String, Value)>,
) -> Result<(), PluginError> {
self.select(references).map(|_| ())
}
fn select(
&self,
references: &BTreeMap<String, (String, Value)>,
) -> Result<Vec<Arc<dyn AgentPlugin>>, PluginError> {
if let Some(issue) =
validate_plugin_references(&self.descriptors().collect::<Vec<_>>(), references)
.into_iter()
.next()
{
return Err(issue.error);
}
let mut selected = Vec::with_capacity(references.len());
let mut configs = BTreeMap::new();
for (id, (version, config)) in references {
let plugin = if let Some(plugin) = self.plugins.get(&(id.clone(), version.clone())) {
plugin
} else if let Some(((_, actual), _)) = self
.plugins
.iter()
.find(|((plugin_id, _), _)| plugin_id == id)
{
return Err(PluginError::DependencyVersion {
plugin: id.clone(),
dependency: id.clone(),
required: version.clone(),
actual: actual.clone(),
});
} else {
return Err(PluginError::MissingDependency(format!("{id}@{version}")));
};
selected.push(Arc::clone(plugin));
configs.insert(id.clone(), config.clone());
}
let borrowed = selected
.iter()
.map(|plugin| plugin.as_ref())
.collect::<Vec<_>>();
resolve_plugin_order(&borrowed)?;
for plugin in &selected {
let manifest = plugin.manifest();
validate_json_schema(&manifest.config_schema, &configs[&manifest.id])
.map_err(|error| PluginError::InvalidConfig(manifest.id.clone(), error))?;
}
Ok(selected)
}
}
#[derive(Debug)]
pub struct PluginReferenceIssue {
pub plugin: String,
pub error: PluginError,
}
pub fn validate_plugin_references(
descriptors: &[PluginDescriptor],
references: &BTreeMap<String, (String, Value)>,
) -> Vec<PluginReferenceIssue> {
let exact = descriptors
.iter()
.map(|descriptor| {
(
(descriptor.id.as_str(), descriptor.version.as_str()),
descriptor,
)
})
.collect::<BTreeMap<_, _>>();
let mut issues = Vec::new();
for (id, (version, _)) in references {
if exact.contains_key(&(id.as_str(), version.as_str())) {
continue;
}
let error = descriptors
.iter()
.find(|descriptor| descriptor.id == *id)
.map_or_else(
|| PluginError::MissingDependency(format!("{id}@{version}")),
|actual| PluginError::DependencyVersion {
plugin: id.clone(),
dependency: id.clone(),
required: version.clone(),
actual: actual.version.clone(),
},
);
issues.push(PluginReferenceIssue {
plugin: id.clone(),
error,
});
}
for (id, (version, _)) in references {
let Some(descriptor) = exact.get(&(id.as_str(), version.as_str())) else {
continue;
};
for (dependency, requirement) in &descriptor.dependencies {
let error = match references.get(dependency).map(|(version, _)| version) {
None => Some(PluginError::MissingDependency(format!(
"{id} -> {dependency}"
))),
Some(actual) => match (VersionReq::parse(requirement), Version::parse(actual)) {
(Err(error), _) => Some(PluginError::InvalidManifest(format!(
"{id} dependency {dependency}: {error}"
))),
(_, Err(error)) => Some(PluginError::InvalidManifest(error.to_string())),
(Ok(required), Ok(actual_version)) if !required.matches(&actual_version) => {
Some(PluginError::DependencyVersion {
plugin: id.clone(),
dependency: dependency.clone(),
required: requirement.clone(),
actual: actual.clone(),
})
}
_ => None,
},
};
if let Some(error) = error {
issues.push(PluginReferenceIssue {
plugin: id.clone(),
error,
});
}
}
}
issues
}
pub async fn mount_plugins(
context: &PluginMountContext,
plugins: &[&dyn AgentPlugin],
configs: &BTreeMap<String, Value>,
) -> Result<MountedPlugins, PluginError> {
let ordered = resolve_plugin_order(plugins)?;
let mut contributions = PluginContributions::default();
let mut leases = Vec::with_capacity(ordered.len());
for plugin in ordered {
let manifest = plugin.manifest();
let config = configs
.get(&manifest.id)
.cloned()
.unwrap_or_else(|| Value::Object(Default::default()));
if let Err(error) = validate_json_schema(&manifest.config_schema, &config) {
return Err(rollback_error(
PluginError::InvalidConfig(manifest.id.clone(), error),
leases,
)
.await);
}
let mut additions = PluginContributions::default();
let mut registrar = AgentRegistrar {
owner: manifest,
contributions: &mut additions,
};
leases.push(plugin.lease(context, &config));
if let Err(error) = plugin.activate(context, &config, &mut registrar).await {
return Err(rollback_error(error, leases).await);
}
if let Err(error) = contributions.merge(additions) {
return Err(rollback_error(error, leases).await);
}
}
Ok(MountedPlugins::from_parts(contributions, leases))
}
async fn rollback_error(cause: PluginError, leases: Vec<Box<dyn PluginLease>>) -> PluginError {
let mut cleanup = MountedPlugins::from_parts(PluginContributions::default(), leases);
match cleanup.unmount().await {
Ok(()) => cause,
Err(error) => PluginError::Rollback {
cause: Box::new(cause),
cleanup_error: error.to_string(),
cleanup: Box::new(cleanup),
},
}
}
pub fn resolve_plugin_order<'a>(
plugins: &'a [&'a dyn AgentPlugin],
) -> Result<Vec<&'a dyn AgentPlugin>, PluginError> {
let by_id = plugins
.iter()
.map(|plugin| (plugin.manifest().id.as_str(), *plugin))
.collect::<BTreeMap<_, _>>();
if by_id.len() != plugins.len() {
return Err(PluginError::Conflict("duplicate plugin id".into()));
}
let mut visiting = BTreeSet::new();
let mut visited = BTreeSet::new();
let mut ordered = Vec::with_capacity(plugins.len());
fn visit<'a>(
id: &'a str,
by_id: &BTreeMap<&'a str, &'a dyn AgentPlugin>,
visiting: &mut BTreeSet<&'a str>,
visited: &mut BTreeSet<&'a str>,
ordered: &mut Vec<&'a dyn AgentPlugin>,
) -> Result<(), PluginError> {
if visited.contains(id) {
return Ok(());
}
if !visiting.insert(id) {
return Err(PluginError::DependencyCycle(id.into()));
}
let plugin = by_id
.get(id)
.ok_or_else(|| PluginError::MissingDependency(id.into()))?;
plugin.manifest().validate()?;
for (dependency, requirement) in &plugin.manifest().dependencies {
if !by_id.contains_key(dependency.as_str()) {
return Err(PluginError::MissingDependency(format!(
"{id} -> {dependency}"
)));
}
let required = VersionReq::parse(requirement).map_err(|error| {
PluginError::InvalidManifest(format!("{id} dependency {dependency}: {error}"))
})?;
let actual = Version::parse(&by_id[dependency.as_str()].manifest().version)
.map_err(|error| PluginError::InvalidManifest(error.to_string()))?;
if !required.matches(&actual) {
return Err(PluginError::DependencyVersion {
plugin: id.into(),
dependency: dependency.clone(),
required: requirement.clone(),
actual: actual.to_string(),
});
}
visit(dependency, by_id, visiting, visited, ordered)?;
}
visiting.remove(id);
visited.insert(id);
ordered.push(*plugin);
Ok(())
}
for id in by_id.keys() {
visit(id, &by_id, &mut visiting, &mut visited, &mut ordered)?;
}
Ok(ordered)
}
fn require(manifest: &PluginManifest, permission: PluginPermission) -> Result<(), PluginError> {
manifest
.permissions
.contains(&permission)
.then_some(())
.ok_or_else(|| PluginError::Permission(format!("{} lacks {permission:?}", manifest.id)))
}
fn insert_once<T>(map: &mut BTreeMap<String, T>, key: String, value: T) -> Result<(), PluginError> {
if map.contains_key(&key) {
return Err(PluginError::Conflict(key));
}
map.insert(key, value);
Ok(())
}
#[derive(Debug, thiserror::Error)]
pub enum PluginError {
#[error("invalid plugin manifest: {0}")]
InvalidManifest(String),
#[error("missing plugin dependency: {0}")]
MissingDependency(String),
#[error("plugin dependency cycle at {0}")]
DependencyCycle(String),
#[error("plugin permission denied: {0}")]
Permission(String),
#[error("plugin registration conflict: {0}")]
Conflict(String),
#[error("plugin mount failed: {0}")]
Mount(String),
#[error("{cause}; plugin rollback failed: {cleanup_error}")]
Rollback {
cause: Box<PluginError>,
cleanup_error: String,
cleanup: Box<MountedPlugins>,
},
#[error("plugin {0} configuration is invalid: {1}")]
InvalidConfig(String, String),
#[error("plugin {plugin} requires {dependency} {required}, found {actual}")]
DependencyVersion {
plugin: String,
dependency: String,
required: String,
actual: String,
},
}
impl PluginError {
pub async fn retry_cleanup(&mut self) -> Result<(), PluginError> {
let Self::Rollback {
cleanup,
cleanup_error,
..
} = self
else {
return Ok(());
};
match cleanup.unmount().await {
Ok(()) => Ok(()),
Err(error) => {
*cleanup_error = error.to_string();
Err(error)
}
}
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use super::*;
use crate::ToolExecutionContext;
struct TestTool;
#[async_trait]
impl Tool for TestTool {
fn name(&self) -> &str {
"test.tool"
}
fn description(&self) -> &str {
"test"
}
fn parameters(&self) -> Value {
serde_json::json!({"type":"object"})
}
fn output_schema(&self) -> Value {
serde_json::json!({"type":"object"})
}
async fn call(&self, arguments: Value) -> Result<Value, String> {
Ok(arguments)
}
}
struct TestHook;
#[async_trait]
impl Hook for TestHook {}
struct Lease(Arc<AtomicUsize>);
#[async_trait]
impl PluginLease for Lease {
async fn unmount(&mut self) -> Result<(), PluginError> {
self.0.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
struct TestPlugin {
manifest: PluginManifest,
fail: bool,
unmounted: Arc<AtomicUsize>,
}
struct RegisteringPlugin {
manifest: PluginManifest,
unmounted: Arc<AtomicUsize>,
}
#[async_trait]
impl AgentPlugin for RegisteringPlugin {
fn manifest(&self) -> &PluginManifest {
&self.manifest
}
fn lease(&self, _: &PluginMountContext, _: &Value) -> Box<dyn PluginLease> {
Box::new(Lease(Arc::clone(&self.unmounted)))
}
async fn activate(
&self,
_: &PluginMountContext,
_: &Value,
registrar: &mut AgentRegistrar<'_>,
) -> Result<(), PluginError> {
registrar.tool(Arc::new(TestTool))?;
Ok(())
}
}
struct RetryLease {
id: &'static str,
fail_once: Arc<AtomicBool>,
calls: Arc<std::sync::Mutex<Vec<&'static str>>>,
}
#[async_trait]
impl PluginLease for RetryLease {
async fn unmount(&mut self) -> Result<(), PluginError> {
self.calls.lock().unwrap().push(self.id);
if self.fail_once.swap(false, Ordering::SeqCst) {
Err(PluginError::Mount(format!("{} failed", self.id)))
} else {
Ok(())
}
}
}
struct CleanupPlugin {
manifest: PluginManifest,
lease_id: &'static str,
fail_activation: bool,
fail_once: Arc<AtomicBool>,
calls: Arc<std::sync::Mutex<Vec<&'static str>>>,
}
#[async_trait]
impl AgentPlugin for CleanupPlugin {
fn manifest(&self) -> &PluginManifest {
&self.manifest
}
fn lease(&self, _: &PluginMountContext, _: &Value) -> Box<dyn PluginLease> {
Box::new(RetryLease {
id: self.lease_id,
fail_once: Arc::clone(&self.fail_once),
calls: Arc::clone(&self.calls),
})
}
async fn activate(
&self,
_: &PluginMountContext,
_: &Value,
_: &mut AgentRegistrar<'_>,
) -> Result<(), PluginError> {
if self.fail_activation {
Err(PluginError::Mount(self.manifest.id.clone()))
} else {
Ok(())
}
}
}
#[async_trait]
impl AgentPlugin for TestPlugin {
fn manifest(&self) -> &PluginManifest {
&self.manifest
}
fn lease(&self, _: &PluginMountContext, _: &Value) -> Box<dyn PluginLease> {
Box::new(Lease(Arc::clone(&self.unmounted)))
}
async fn activate(
&self,
_context: &PluginMountContext,
_config: &Value,
_registrar: &mut AgentRegistrar<'_>,
) -> Result<(), PluginError> {
if self.fail {
Err(PluginError::Mount(self.manifest.id.clone()))
} else {
Ok(())
}
}
}
fn plugin(
id: &str,
dependencies: BTreeMap<String, String>,
fail: bool,
unmounted: Arc<AtomicUsize>,
) -> TestPlugin {
TestPlugin {
manifest: PluginManifest {
id: id.into(),
version: "1.0.0".into(),
dependencies,
config_schema: serde_json::json!({"type":"object"}),
permissions: BTreeSet::new(),
},
fail,
unmounted,
}
}
#[tokio::test]
async fn activation_failure_rolls_back_current_and_prior_leases() {
let unmounted = Arc::new(AtomicUsize::new(0));
let first = plugin("first", BTreeMap::new(), false, Arc::clone(&unmounted));
let second = plugin(
"second",
BTreeMap::from([("first".into(), "^1".into())]),
true,
Arc::clone(&unmounted),
);
let context = PluginMountContext {
tenant_id: "t".parse().unwrap(),
subject_id: "s".parse().unwrap(),
session_id: "session".parse().unwrap(),
profile_revision_id: "profile".parse().unwrap(),
};
assert!(
mount_plugins(&context, &[&second, &first], &BTreeMap::new())
.await
.is_err()
);
assert_eq!(unmounted.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn activation_failure_cleans_current_plugin_and_retries_failed_cleanup() {
let calls = Arc::new(std::sync::Mutex::new(Vec::new()));
let first = CleanupPlugin {
manifest: plugin(
"first",
BTreeMap::new(),
false,
Arc::new(AtomicUsize::new(0)),
)
.manifest,
lease_id: "first",
fail_activation: false,
fail_once: Arc::new(AtomicBool::new(false)),
calls: Arc::clone(&calls),
};
let second = CleanupPlugin {
manifest: plugin(
"second",
BTreeMap::from([("first".into(), "^1".into())]),
false,
Arc::new(AtomicUsize::new(0)),
)
.manifest,
lease_id: "second",
fail_activation: true,
fail_once: Arc::new(AtomicBool::new(true)),
calls: Arc::clone(&calls),
};
let context = PluginMountContext {
tenant_id: "t".parse().unwrap(),
subject_id: "s".parse().unwrap(),
session_id: "rollback".parse().unwrap(),
profile_revision_id: "profile".parse().unwrap(),
};
let mut error = mount_plugins(&context, &[&second, &first], &BTreeMap::new())
.await
.unwrap_err();
assert!(matches!(error, PluginError::Rollback { .. }));
assert_eq!(*calls.lock().unwrap(), vec!["second", "first"]);
error.retry_cleanup().await.unwrap();
assert_eq!(*calls.lock().unwrap(), vec!["second", "first", "second"]);
}
#[tokio::test]
async fn registration_conflict_rolls_back_current_and_prior_leases() {
let unmounted = Arc::new(AtomicUsize::new(0));
let manifest = |id: &str, dependencies| PluginManifest {
id: id.into(),
version: "1.0.0".into(),
dependencies,
config_schema: serde_json::json!({"type":"object"}),
permissions: BTreeSet::from([PluginPermission::Tool]),
};
let first = RegisteringPlugin {
manifest: manifest("first", BTreeMap::new()),
unmounted: Arc::clone(&unmounted),
};
let second = RegisteringPlugin {
manifest: manifest("second", BTreeMap::from([("first".into(), "^1".into())])),
unmounted: Arc::clone(&unmounted),
};
let context = PluginMountContext {
tenant_id: "t".parse().unwrap(),
subject_id: "s".parse().unwrap(),
session_id: "session".parse().unwrap(),
profile_revision_id: "profile".parse().unwrap(),
};
assert!(matches!(
mount_plugins(&context, &[&second, &first], &BTreeMap::new()).await,
Err(PluginError::Conflict(_))
));
assert_eq!(unmounted.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn session_lease_unmounts_after_last_runtime_clone() {
let unmounted = Arc::new(AtomicUsize::new(0));
let plugin = plugin("session", BTreeMap::new(), false, Arc::clone(&unmounted));
let context = PluginMountContext {
tenant_id: "t".parse().unwrap(),
subject_id: "s".parse().unwrap(),
session_id: "session".parse().unwrap(),
profile_revision_id: "profile".parse().unwrap(),
};
let mounted = mount_plugins(&context, &[&plugin], &BTreeMap::new())
.await
.unwrap();
let runtime_clone = mounted.clone();
let teardown = tokio::spawn(async move {
let mut mounted = mounted;
mounted.unmount().await
});
tokio::task::yield_now().await;
assert_eq!(unmounted.load(Ordering::SeqCst), 0);
drop(runtime_clone);
teardown.await.unwrap().unwrap();
assert_eq!(unmounted.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn cleanup_attempts_every_lease_and_retries_only_failures() {
let calls = Arc::new(std::sync::Mutex::new(Vec::new()));
let first = CleanupPlugin {
manifest: plugin(
"first-cleanup",
BTreeMap::new(),
false,
Arc::new(AtomicUsize::new(0)),
)
.manifest,
lease_id: "first",
fail_activation: false,
fail_once: Arc::new(AtomicBool::new(false)),
calls: Arc::clone(&calls),
};
let second = CleanupPlugin {
manifest: plugin(
"second-cleanup",
BTreeMap::from([("first-cleanup".into(), "^1".into())]),
false,
Arc::new(AtomicUsize::new(0)),
)
.manifest,
lease_id: "second",
fail_activation: false,
fail_once: Arc::new(AtomicBool::new(true)),
calls: Arc::clone(&calls),
};
let context = PluginMountContext {
tenant_id: "t".parse().unwrap(),
subject_id: "s".parse().unwrap(),
session_id: "cleanup".parse().unwrap(),
profile_revision_id: "profile".parse().unwrap(),
};
let mut mounted = mount_plugins(&context, &[&second, &first], &BTreeMap::new())
.await
.unwrap();
assert!(mounted.unmount().await.is_err());
assert_eq!(*calls.lock().unwrap(), vec!["second", "first"]);
mounted.unmount().await.unwrap();
assert_eq!(*calls.lock().unwrap(), vec!["second", "first", "second"]);
}
#[test]
fn dependency_versions_and_cycles_fail_before_mount() {
let counter = Arc::new(AtomicUsize::new(0));
let base = plugin("base", BTreeMap::new(), false, Arc::clone(&counter));
let incompatible = plugin(
"consumer",
BTreeMap::from([("base".into(), "^2".into())]),
false,
Arc::clone(&counter),
);
assert!(matches!(
resolve_plugin_order(&[&base, &incompatible]),
Err(PluginError::DependencyVersion { .. })
));
let left = plugin(
"left",
BTreeMap::from([("right".into(), "^1".into())]),
false,
Arc::clone(&counter),
);
let right = plugin(
"right",
BTreeMap::from([("left".into(), "^1".into())]),
false,
counter,
);
assert!(matches!(
resolve_plugin_order(&[&left, &right]),
Err(PluginError::DependencyCycle(_))
));
}
#[test]
fn descriptor_preflight_rejects_missing_and_incompatible_dependencies() {
let descriptors = vec![
PluginDescriptor {
id: "base".into(),
version: "1.0.0".into(),
dependencies: BTreeMap::new(),
permissions: BTreeSet::new(),
},
PluginDescriptor {
id: "child".into(),
version: "1.0.0".into(),
dependencies: BTreeMap::from([("base".into(), "^2".into())]),
permissions: BTreeSet::new(),
},
];
let references = BTreeMap::from([
("base".into(), ("1.0.0".into(), Value::Null)),
("child".into(), ("1.0.0".into(), Value::Null)),
]);
let issues = validate_plugin_references(&descriptors, &references);
assert_eq!(issues.len(), 1);
assert!(matches!(
issues[0].error,
PluginError::DependencyVersion { .. }
));
}
#[test]
fn descriptor_preflight_reports_every_missing_dependency() {
let descriptors = vec![PluginDescriptor {
id: "child".into(),
version: "1.0.0".into(),
dependencies: BTreeMap::from([
("first".into(), "^1".into()),
("second".into(), "^1".into()),
]),
permissions: BTreeSet::new(),
}];
let references = BTreeMap::from([("child".into(), ("1.0.0".into(), Value::Null))]);
let issues = validate_plugin_references(&descriptors, &references);
assert_eq!(issues.len(), 2);
assert!(issues.iter().all(|issue| issue.plugin == "child"
&& matches!(issue.error, PluginError::MissingDependency(_))));
}
#[tokio::test]
async fn registrar_and_catalog_enforce_manifest_permissions_atomically() {
let manifest = PluginManifest {
id: "test.plugin".into(),
version: "1.0.0".into(),
dependencies: BTreeMap::new(),
config_schema: serde_json::json!({"type":"object","required":["enabled"]}),
permissions: BTreeSet::from([
PluginPermission::Prompt,
PluginPermission::Tool,
PluginPermission::Hook,
]),
};
manifest.validate().unwrap();
for invalid in [
PluginManifest {
id: "Bad Plugin".into(),
..manifest.clone()
},
PluginManifest {
version: "latest".into(),
..manifest.clone()
},
] {
assert!(matches!(
invalid.validate(),
Err(PluginError::InvalidManifest(_))
));
}
let mut contributions = PluginContributions::default();
{
let mut registrar = AgentRegistrar {
owner: &manifest,
contributions: &mut contributions,
};
registrar.prompt("system", "prompt").unwrap();
assert!(matches!(
registrar.prompt("system", "again"),
Err(PluginError::Conflict(_))
));
registrar.tool(Arc::new(TestTool)).unwrap();
assert!(matches!(
registrar.tool(Arc::new(TestTool)),
Err(PluginError::Conflict(_))
));
registrar.hook(Arc::new(TestHook)).unwrap();
}
let prompt = contributions.prompts.values().next().unwrap();
assert_eq!(prompt.id, "plugin.test.plugin.system");
assert_eq!(prompt.source, "test.plugin");
assert_eq!(prompt.version, "1.0.0");
let denied = PluginManifest {
permissions: BTreeSet::new(),
..manifest.clone()
};
let mut denied_contributions = PluginContributions::default();
let mut denied_registrar = AgentRegistrar {
owner: &denied,
contributions: &mut denied_contributions,
};
assert!(matches!(
denied_registrar.prompt("x", "x"),
Err(PluginError::Permission(_))
));
let context = ToolExecutionContext {
request: crate::RequestContext {
tenant_id: "tenant".parse().unwrap(),
subject_id: "subject".parse().unwrap(),
roles: Default::default(),
locale: "en".into(),
request_id: "request".parse().unwrap(),
entitlements: Default::default(),
},
session_id: "session".parse().unwrap(),
run_id: "run".parse().unwrap(),
step: 1,
call_id: "call".parse().unwrap(),
source_event_seq: 1,
interaction_resolution: None,
cancellation: crate::CancellationToken::default(),
deadline: std::time::Instant::now() + std::time::Duration::from_secs(15),
};
assert_eq!(
TestHook
.before_tool(&context, "test.tool", &serde_json::json!({}))
.await,
HookDecision::Continue
);
TestHook
.after_tool(
&context,
"test.tool",
&serde_json::json!({}),
&serde_json::json!({}),
)
.await
.unwrap();
let unmounted = Arc::new(AtomicUsize::new(0));
let plugin = Arc::new(TestPlugin {
manifest: manifest.clone(),
fail: false,
unmounted: Arc::clone(&unmounted),
});
let mut catalog = PluginCatalog::default();
catalog.register(plugin.clone()).unwrap();
assert!(matches!(
catalog.register(plugin),
Err(PluginError::Conflict(_))
));
let mount_context = PluginMountContext {
tenant_id: "t".parse().unwrap(),
subject_id: "s".parse().unwrap(),
session_id: "session".parse().unwrap(),
profile_revision_id: "profile".parse().unwrap(),
};
assert!(matches!(
catalog
.mount(
&mount_context,
&BTreeMap::from([("missing".into(), ("1.0.0".into(), serde_json::json!({})))])
)
.await,
Err(PluginError::MissingDependency(_))
));
assert!(matches!(
catalog
.mount(
&mount_context,
&BTreeMap::from([(
"test.plugin".into(),
("2.0.0".into(), serde_json::json!({}))
)])
)
.await,
Err(PluginError::DependencyVersion { .. })
));
assert!(matches!(
catalog
.mount(
&mount_context,
&BTreeMap::from([(
"test.plugin".into(),
("1.0.0".into(), serde_json::json!({}))
)])
)
.await,
Err(PluginError::InvalidConfig(_, _))
));
let mut mounted = catalog
.mount(
&mount_context,
&BTreeMap::from([(
"test.plugin".into(),
("1.0.0".into(), serde_json::json!({"enabled":true})),
)]),
)
.await
.unwrap();
mounted.unmount().await.unwrap();
assert_eq!(unmounted.load(Ordering::SeqCst), 1);
}
}