1use af_agent_session::ContentBlock;
2use af_context::{ProfileRevisionId, RunId, SessionId, SubjectId, TenantId};
3use std::collections::{BTreeMap, BTreeSet};
4use std::future::Future;
5use std::pin::Pin;
6use std::sync::Arc;
7
8use async_trait::async_trait;
9use semver::{Version, VersionReq};
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13use crate::{
14 validate_json_schema, ContextContributor, PromptAuthority, PromptSection, Tool, ToolRegistry,
15};
16
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19pub struct PluginManifest {
20 pub id: String,
22 pub version: String,
24 #[serde(default)]
26 pub dependencies: BTreeMap<String, String>,
27 #[serde(default)]
29 pub config_schema: Value,
30 #[serde(default)]
32 pub permissions: BTreeSet<PluginPermission>,
33}
34
35impl PluginManifest {
36 pub fn validate(&self) -> Result<(), PluginError> {
38 if self.id.trim().is_empty()
39 || !self.id.chars().all(|ch| {
40 ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '.' | '-' | '_')
41 })
42 {
43 return Err(PluginError::InvalidManifest(format!(
44 "invalid plugin id {}",
45 self.id
46 )));
47 }
48 Version::parse(&self.version)
49 .map_err(|error| PluginError::InvalidManifest(format!("{}: {error}", self.id)))?;
50 Ok(())
51 }
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum PluginPermission {
58 Prompt,
60 Context,
62 Tool,
64 Hook,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct PluginDescriptor {
71 pub id: String,
73 pub version: String,
75 pub dependencies: BTreeMap<String, String>,
77 pub permissions: BTreeSet<PluginPermission>,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct PluginMountContext {
84 pub tenant_id: TenantId,
86 pub subject_id: SubjectId,
88 pub session_id: SessionId,
90 pub profile_revision_id: ProfileRevisionId,
92}
93
94#[derive(Debug, Clone, PartialEq)]
96pub enum HookDecision {
97 Continue,
99 Deny {
101 reason: String,
103 },
104 WaitForInput {
106 kind: String,
108 payload: Value,
110 },
111}
112
113pub type ToolExecutionFuture<'a> = Pin<Box<dyn Future<Output = Result<Value, String>> + Send + 'a>>;
115
116#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct StepContext {
119 pub request: af_context::RequestContext,
121 pub session_id: SessionId,
123 pub run_id: RunId,
125 pub turn: u32,
127 pub step: u32,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
134pub enum PreStepDecision {
135 Enter,
137 Reject {
139 reason: String,
141 },
142}
143
144#[derive(Debug, Clone, PartialEq)]
147pub struct ModelRequestPolicy {
148 pub model: String,
150 pub reasoning_effort: Option<af_llm::ReasoningEffort>,
152 pub temperature: f32,
154 pub max_tokens: Option<u32>,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct ModelFailure {
161 pub attempt: u32,
163 pub error: String,
165 pub retryable: bool,
167 pub attempts_remaining: bool,
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub enum RequestErrorAction {
176 Default,
178 Retry,
180 Terminal,
182}
183
184#[derive(Debug, Clone, PartialEq)]
187pub enum TurnStopDecision {
188 Stop,
190 Steer {
192 content: Vec<ContentBlock>,
194 },
195}
196
197#[async_trait]
199pub trait Hook: Send + Sync {
200 async fn pre_step(&self, _context: &StepContext) -> PreStepDecision {
202 PreStepDecision::Enter
203 }
204 async fn request(&self, _context: &StepContext, _policy: &mut ModelRequestPolicy) {}
206 async fn request_error(
208 &self,
209 _context: &StepContext,
210 _failure: &ModelFailure,
211 ) -> RequestErrorAction {
212 RequestErrorAction::Default
213 }
214 async fn turn_stopping(&self, _context: &StepContext) -> TurnStopDecision {
216 TurnStopDecision::Stop
217 }
218 async fn before_tool(
220 &self,
221 _context: &crate::ToolExecutionContext,
222 _tool: &str,
223 _arguments: &Value,
224 ) -> HookDecision {
225 HookDecision::Continue
226 }
227 fn around_tool<'a>(
229 &'a self,
230 _context: &'a crate::ToolExecutionContext,
231 _tool: &'a str,
232 _arguments: &'a Value,
233 next: ToolExecutionFuture<'a>,
234 ) -> ToolExecutionFuture<'a> {
235 next
236 }
237 async fn after_tool(
239 &self,
240 _context: &crate::ToolExecutionContext,
241 _tool: &str,
242 _arguments: &Value,
243 _result: &Value,
244 ) -> Result<(), PluginError> {
245 Ok(())
246 }
247}
248
249#[derive(Default, Clone)]
250struct PluginContributions {
251 prompts: BTreeMap<String, PromptSection>,
252 contexts: Vec<Arc<dyn ContextContributor>>,
253 tools: ToolRegistry,
254 hooks: Vec<Arc<dyn Hook>>,
255}
256
257impl PluginContributions {
258 fn merge(&mut self, additions: Self) -> Result<(), PluginError> {
259 let mut merged = self.clone();
260 for (id, prompt) in additions.prompts {
261 insert_once(&mut merged.prompts, id, prompt)?;
262 }
263 merged
264 .tools
265 .extend(&additions.tools)
266 .map_err(PluginError::Conflict)?;
267 merged.contexts.extend(additions.contexts);
268 merged.hooks.extend(additions.hooks);
269 *self = merged;
270 Ok(())
271 }
272}
273
274pub struct AgentRegistrar<'a> {
276 owner: &'a PluginManifest,
277 contributions: &'a mut PluginContributions,
278}
279
280impl AgentRegistrar<'_> {
281 pub fn prompt(
283 &mut self,
284 id: impl Into<String>,
285 prompt: impl Into<String>,
286 ) -> Result<(), PluginError> {
287 require(self.owner, PluginPermission::Prompt)?;
288 let id = format!("plugin.{}.{}", self.owner.id, id.into());
289 insert_once(
290 &mut self.contributions.prompts,
291 id.clone(),
292 PromptSection {
293 id,
294 order: 400,
295 authority: PromptAuthority::Plugin,
296 source: self.owner.id.clone(),
297 version: self.owner.version.clone(),
298 content: prompt.into(),
299 },
300 )
301 }
302 pub fn tool(&mut self, tool: Arc<dyn Tool>) -> Result<(), PluginError> {
304 require(self.owner, PluginPermission::Tool)?;
305 if self.contributions.tools.contains(tool.name()) {
306 return Err(PluginError::Conflict(format!("tool {}", tool.name())));
307 }
308 self.contributions
309 .tools
310 .register(tool)
311 .map(|_| ())
312 .map_err(PluginError::Conflict)
313 }
314 pub fn context(&mut self, contributor: Arc<dyn ContextContributor>) -> Result<(), PluginError> {
316 require(self.owner, PluginPermission::Context)?;
317 self.contributions.contexts.push(contributor);
318 Ok(())
319 }
320 pub fn hook(&mut self, hook: Arc<dyn Hook>) -> Result<(), PluginError> {
322 require(self.owner, PluginPermission::Hook)?;
323 self.contributions.hooks.push(hook);
324 Ok(())
325 }
326}
327
328#[async_trait]
330pub trait PluginLease: Send + Sync {
331 async fn unmount(&mut self) -> Result<(), PluginError>;
333}
334
335#[async_trait]
337pub trait AgentPlugin: Send + Sync {
338 fn manifest(&self) -> &PluginManifest;
340
341 fn lease(&self, context: &PluginMountContext, config: &Value) -> Box<dyn PluginLease>;
344
345 async fn activate(
347 &self,
348 context: &PluginMountContext,
349 config: &Value,
350 registrar: &mut AgentRegistrar<'_>,
351 ) -> Result<(), PluginError>;
352}
353
354pub struct MountedPlugins {
356 contributions: PluginContributions,
357 leases: Arc<LeaseSet>,
358}
359
360impl std::fmt::Debug for MountedPlugins {
361 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
362 formatter
363 .debug_struct("MountedPlugins")
364 .field(
365 "pending_leases",
366 &self
367 .leases
368 .state
369 .lock()
370 .unwrap_or_else(std::sync::PoisonError::into_inner)
371 .leases
372 .len(),
373 )
374 .finish_non_exhaustive()
375 }
376}
377
378impl MountedPlugins {
379 fn from_parts(contributions: PluginContributions, leases: Vec<Box<dyn PluginLease>>) -> Self {
380 Self {
381 contributions,
382 leases: Arc::new(LeaseSet {
383 state: std::sync::Mutex::new(LeaseState {
384 leases,
385 holders: 1,
386 closing: false,
387 }),
388 notify: tokio::sync::Notify::new(),
389 }),
390 }
391 }
392
393 pub fn prompts(&self) -> impl Iterator<Item = &PromptSection> {
395 self.contributions.prompts.values()
396 }
397 pub fn contexts(&self) -> &[Arc<dyn ContextContributor>] {
399 &self.contributions.contexts
400 }
401 pub fn tools(&self) -> &ToolRegistry {
403 &self.contributions.tools
404 }
405 pub fn hooks(&self) -> &[Arc<dyn Hook>] {
407 &self.contributions.hooks
408 }
409
410 pub async fn unmount(&mut self) -> Result<(), PluginError> {
412 let leases = Arc::clone(&self.leases);
413 {
414 leases
415 .state
416 .lock()
417 .unwrap_or_else(std::sync::PoisonError::into_inner)
418 .closing = true;
419 }
420 loop {
421 let notified = leases.notify.notified();
422 let pending = {
423 let mut state = leases
424 .state
425 .lock()
426 .unwrap_or_else(std::sync::PoisonError::into_inner);
427 (state.holders == 1).then(|| std::mem::take(&mut state.leases))
428 };
429 if let Some(mut pending) = pending {
430 let mut failed = Vec::new();
431 let mut errors = Vec::new();
432 while let Some(mut lease) = pending.pop() {
433 if let Err(error) = lease.unmount().await {
434 errors.push(error.to_string());
435 failed.push(lease);
436 }
437 }
438 leases
439 .state
440 .lock()
441 .unwrap_or_else(std::sync::PoisonError::into_inner)
442 .leases = failed;
443 return if errors.is_empty() {
444 Ok(())
445 } else {
446 Err(PluginError::Mount(format!(
447 "plugin cleanup failed: {}",
448 errors.join("; ")
449 )))
450 };
451 }
452 notified.await;
453 }
454 }
455}
456
457impl Clone for MountedPlugins {
458 fn clone(&self) -> Self {
459 self.leases
460 .state
461 .lock()
462 .unwrap_or_else(std::sync::PoisonError::into_inner)
463 .holders += 1;
464 Self {
465 contributions: self.contributions.clone(),
466 leases: Arc::clone(&self.leases),
467 }
468 }
469}
470
471impl Drop for MountedPlugins {
472 fn drop(&mut self) {
473 let mut state = self
474 .leases
475 .state
476 .lock()
477 .unwrap_or_else(std::sync::PoisonError::into_inner);
478 state.holders -= 1;
479 if state.closing && state.holders <= 1 {
480 self.leases.notify.notify_waiters();
481 }
482 }
483}
484
485struct LeaseSet {
486 state: std::sync::Mutex<LeaseState>,
487 notify: tokio::sync::Notify,
488}
489
490struct LeaseState {
491 leases: Vec<Box<dyn PluginLease>>,
492 holders: usize,
493 closing: bool,
494}
495
496#[derive(Default)]
498pub struct PluginCatalog {
499 plugins: BTreeMap<(String, String), Arc<dyn AgentPlugin>>,
500}
501
502impl std::fmt::Debug for PluginCatalog {
503 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
504 formatter
505 .debug_struct("PluginCatalog")
506 .field("count", &self.plugins.len())
507 .finish()
508 }
509}
510
511impl PluginCatalog {
512 pub fn register(&mut self, plugin: Arc<dyn AgentPlugin>) -> Result<(), PluginError> {
514 plugin.manifest().validate()?;
515 let id = plugin.manifest().id.clone();
516 let version = plugin.manifest().version.clone();
517 if self.plugins.contains_key(&(id.clone(), version.clone())) {
518 return Err(PluginError::Conflict(format!("{id}@{version}")));
519 }
520 self.plugins.insert((id, version), plugin);
521 Ok(())
522 }
523
524 pub fn descriptors(&self) -> impl Iterator<Item = PluginDescriptor> + '_ {
526 self.plugins.values().map(|plugin| {
527 let manifest = plugin.manifest();
528 PluginDescriptor {
529 id: manifest.id.clone(),
530 version: manifest.version.clone(),
531 dependencies: manifest.dependencies.clone(),
532 permissions: manifest.permissions.clone(),
533 }
534 })
535 }
536
537 pub async fn mount(
539 &self,
540 context: &PluginMountContext,
541 references: &BTreeMap<String, (String, Value)>,
542 ) -> Result<MountedPlugins, PluginError> {
543 let selected = self.select(references)?;
544 let configs = references
545 .iter()
546 .map(|(id, (_, config))| (id.clone(), config.clone()))
547 .collect();
548 let borrowed = selected
549 .iter()
550 .map(|plugin| plugin.as_ref())
551 .collect::<Vec<_>>();
552 mount_plugins(context, &borrowed, &configs).await
553 }
554
555 pub fn validate_references(
557 &self,
558 references: &BTreeMap<String, (String, Value)>,
559 ) -> Result<(), PluginError> {
560 self.select(references).map(|_| ())
561 }
562
563 fn select(
564 &self,
565 references: &BTreeMap<String, (String, Value)>,
566 ) -> Result<Vec<Arc<dyn AgentPlugin>>, PluginError> {
567 if let Some(issue) =
568 validate_plugin_references(&self.descriptors().collect::<Vec<_>>(), references)
569 .into_iter()
570 .next()
571 {
572 return Err(issue.error);
573 }
574 let mut selected = Vec::with_capacity(references.len());
575 let mut configs = BTreeMap::new();
576 for (id, (version, config)) in references {
577 let plugin = if let Some(plugin) = self.plugins.get(&(id.clone(), version.clone())) {
578 plugin
579 } else if let Some(((_, actual), _)) = self
580 .plugins
581 .iter()
582 .find(|((plugin_id, _), _)| plugin_id == id)
583 {
584 return Err(PluginError::DependencyVersion {
585 plugin: id.clone(),
586 dependency: id.clone(),
587 required: version.clone(),
588 actual: actual.clone(),
589 });
590 } else {
591 return Err(PluginError::MissingDependency(format!("{id}@{version}")));
592 };
593 selected.push(Arc::clone(plugin));
594 configs.insert(id.clone(), config.clone());
595 }
596 let borrowed = selected
597 .iter()
598 .map(|plugin| plugin.as_ref())
599 .collect::<Vec<_>>();
600 resolve_plugin_order(&borrowed)?;
601 for plugin in &selected {
602 let manifest = plugin.manifest();
603 validate_json_schema(&manifest.config_schema, &configs[&manifest.id])
604 .map_err(|error| PluginError::InvalidConfig(manifest.id.clone(), error))?;
605 }
606 Ok(selected)
607 }
608}
609
610#[derive(Debug)]
612pub struct PluginReferenceIssue {
613 pub plugin: String,
615 pub error: PluginError,
617}
618
619pub fn validate_plugin_references(
621 descriptors: &[PluginDescriptor],
622 references: &BTreeMap<String, (String, Value)>,
623) -> Vec<PluginReferenceIssue> {
624 let exact = descriptors
625 .iter()
626 .map(|descriptor| {
627 (
628 (descriptor.id.as_str(), descriptor.version.as_str()),
629 descriptor,
630 )
631 })
632 .collect::<BTreeMap<_, _>>();
633 let mut issues = Vec::new();
634 for (id, (version, _)) in references {
635 if exact.contains_key(&(id.as_str(), version.as_str())) {
636 continue;
637 }
638 let error = descriptors
639 .iter()
640 .find(|descriptor| descriptor.id == *id)
641 .map_or_else(
642 || PluginError::MissingDependency(format!("{id}@{version}")),
643 |actual| PluginError::DependencyVersion {
644 plugin: id.clone(),
645 dependency: id.clone(),
646 required: version.clone(),
647 actual: actual.version.clone(),
648 },
649 );
650 issues.push(PluginReferenceIssue {
651 plugin: id.clone(),
652 error,
653 });
654 }
655 for (id, (version, _)) in references {
656 let Some(descriptor) = exact.get(&(id.as_str(), version.as_str())) else {
657 continue;
658 };
659 for (dependency, requirement) in &descriptor.dependencies {
660 let error = match references.get(dependency).map(|(version, _)| version) {
661 None => Some(PluginError::MissingDependency(format!(
662 "{id} -> {dependency}"
663 ))),
664 Some(actual) => match (VersionReq::parse(requirement), Version::parse(actual)) {
665 (Err(error), _) => Some(PluginError::InvalidManifest(format!(
666 "{id} dependency {dependency}: {error}"
667 ))),
668 (_, Err(error)) => Some(PluginError::InvalidManifest(error.to_string())),
669 (Ok(required), Ok(actual_version)) if !required.matches(&actual_version) => {
670 Some(PluginError::DependencyVersion {
671 plugin: id.clone(),
672 dependency: dependency.clone(),
673 required: requirement.clone(),
674 actual: actual.clone(),
675 })
676 }
677 _ => None,
678 },
679 };
680 if let Some(error) = error {
681 issues.push(PluginReferenceIssue {
682 plugin: id.clone(),
683 error,
684 });
685 }
686 }
687 }
688 issues
689}
690
691pub async fn mount_plugins(
694 context: &PluginMountContext,
695 plugins: &[&dyn AgentPlugin],
696 configs: &BTreeMap<String, Value>,
697) -> Result<MountedPlugins, PluginError> {
698 let ordered = resolve_plugin_order(plugins)?;
699 let mut contributions = PluginContributions::default();
700 let mut leases = Vec::with_capacity(ordered.len());
701 for plugin in ordered {
702 let manifest = plugin.manifest();
703 let config = configs
704 .get(&manifest.id)
705 .cloned()
706 .unwrap_or_else(|| Value::Object(Default::default()));
707 if let Err(error) = validate_json_schema(&manifest.config_schema, &config) {
708 return Err(rollback_error(
709 PluginError::InvalidConfig(manifest.id.clone(), error),
710 leases,
711 )
712 .await);
713 }
714 let mut additions = PluginContributions::default();
715 let mut registrar = AgentRegistrar {
716 owner: manifest,
717 contributions: &mut additions,
718 };
719 leases.push(plugin.lease(context, &config));
720 if let Err(error) = plugin.activate(context, &config, &mut registrar).await {
721 return Err(rollback_error(error, leases).await);
722 }
723 if let Err(error) = contributions.merge(additions) {
724 return Err(rollback_error(error, leases).await);
725 }
726 }
727 Ok(MountedPlugins::from_parts(contributions, leases))
728}
729
730async fn rollback_error(cause: PluginError, leases: Vec<Box<dyn PluginLease>>) -> PluginError {
731 let mut cleanup = MountedPlugins::from_parts(PluginContributions::default(), leases);
732 match cleanup.unmount().await {
733 Ok(()) => cause,
734 Err(error) => PluginError::Rollback {
735 cause: Box::new(cause),
736 cleanup_error: error.to_string(),
737 cleanup: Box::new(cleanup),
738 },
739 }
740}
741
742pub fn resolve_plugin_order<'a>(
744 plugins: &'a [&'a dyn AgentPlugin],
745) -> Result<Vec<&'a dyn AgentPlugin>, PluginError> {
746 let by_id = plugins
747 .iter()
748 .map(|plugin| (plugin.manifest().id.as_str(), *plugin))
749 .collect::<BTreeMap<_, _>>();
750 if by_id.len() != plugins.len() {
751 return Err(PluginError::Conflict("duplicate plugin id".into()));
752 }
753 let mut visiting = BTreeSet::new();
754 let mut visited = BTreeSet::new();
755 let mut ordered = Vec::with_capacity(plugins.len());
756 fn visit<'a>(
757 id: &'a str,
758 by_id: &BTreeMap<&'a str, &'a dyn AgentPlugin>,
759 visiting: &mut BTreeSet<&'a str>,
760 visited: &mut BTreeSet<&'a str>,
761 ordered: &mut Vec<&'a dyn AgentPlugin>,
762 ) -> Result<(), PluginError> {
763 if visited.contains(id) {
764 return Ok(());
765 }
766 if !visiting.insert(id) {
767 return Err(PluginError::DependencyCycle(id.into()));
768 }
769 let plugin = by_id
770 .get(id)
771 .ok_or_else(|| PluginError::MissingDependency(id.into()))?;
772 plugin.manifest().validate()?;
773 for (dependency, requirement) in &plugin.manifest().dependencies {
774 if !by_id.contains_key(dependency.as_str()) {
775 return Err(PluginError::MissingDependency(format!(
776 "{id} -> {dependency}"
777 )));
778 }
779 let required = VersionReq::parse(requirement).map_err(|error| {
780 PluginError::InvalidManifest(format!("{id} dependency {dependency}: {error}"))
781 })?;
782 let actual = Version::parse(&by_id[dependency.as_str()].manifest().version)
783 .map_err(|error| PluginError::InvalidManifest(error.to_string()))?;
784 if !required.matches(&actual) {
785 return Err(PluginError::DependencyVersion {
786 plugin: id.into(),
787 dependency: dependency.clone(),
788 required: requirement.clone(),
789 actual: actual.to_string(),
790 });
791 }
792 visit(dependency, by_id, visiting, visited, ordered)?;
793 }
794 visiting.remove(id);
795 visited.insert(id);
796 ordered.push(*plugin);
797 Ok(())
798 }
799 for id in by_id.keys() {
800 visit(id, &by_id, &mut visiting, &mut visited, &mut ordered)?;
801 }
802 Ok(ordered)
803}
804
805fn require(manifest: &PluginManifest, permission: PluginPermission) -> Result<(), PluginError> {
806 manifest
807 .permissions
808 .contains(&permission)
809 .then_some(())
810 .ok_or_else(|| PluginError::Permission(format!("{} lacks {permission:?}", manifest.id)))
811}
812
813fn insert_once<T>(map: &mut BTreeMap<String, T>, key: String, value: T) -> Result<(), PluginError> {
814 if map.contains_key(&key) {
815 return Err(PluginError::Conflict(key));
816 }
817 map.insert(key, value);
818 Ok(())
819}
820
821#[derive(Debug, thiserror::Error)]
823pub enum PluginError {
824 #[error("invalid plugin manifest: {0}")]
826 InvalidManifest(String),
827 #[error("missing plugin dependency: {0}")]
829 MissingDependency(String),
830 #[error("plugin dependency cycle at {0}")]
832 DependencyCycle(String),
833 #[error("plugin permission denied: {0}")]
835 Permission(String),
836 #[error("plugin registration conflict: {0}")]
838 Conflict(String),
839 #[error("plugin mount failed: {0}")]
841 Mount(String),
842 #[error("{cause}; plugin rollback failed: {cleanup_error}")]
844 Rollback {
845 cause: Box<PluginError>,
847 cleanup_error: String,
849 cleanup: Box<MountedPlugins>,
851 },
852 #[error("plugin {0} configuration is invalid: {1}")]
854 InvalidConfig(String, String),
855 #[error("plugin {plugin} requires {dependency} {required}, found {actual}")]
857 DependencyVersion {
858 plugin: String,
860 dependency: String,
862 required: String,
864 actual: String,
866 },
867}
868
869impl PluginError {
870 pub async fn retry_cleanup(&mut self) -> Result<(), PluginError> {
872 let Self::Rollback {
873 cleanup,
874 cleanup_error,
875 ..
876 } = self
877 else {
878 return Ok(());
879 };
880 match cleanup.unmount().await {
881 Ok(()) => Ok(()),
882 Err(error) => {
883 *cleanup_error = error.to_string();
884 Err(error)
885 }
886 }
887 }
888}
889
890#[cfg(test)]
891mod tests {
892 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
893
894 use super::*;
895 use crate::ToolExecutionContext;
896
897 struct TestTool;
898 #[async_trait]
899 impl Tool for TestTool {
900 fn name(&self) -> &str {
901 "test.tool"
902 }
903 fn description(&self) -> &str {
904 "test"
905 }
906 fn parameters(&self) -> Value {
907 serde_json::json!({"type":"object"})
908 }
909 fn output_schema(&self) -> Value {
910 serde_json::json!({"type":"object"})
911 }
912 async fn call(&self, arguments: Value) -> Result<Value, String> {
913 Ok(arguments)
914 }
915 }
916
917 struct TestHook;
918 #[async_trait]
919 impl Hook for TestHook {}
920
921 struct Lease(Arc<AtomicUsize>);
922 #[async_trait]
923 impl PluginLease for Lease {
924 async fn unmount(&mut self) -> Result<(), PluginError> {
925 self.0.fetch_add(1, Ordering::SeqCst);
926 Ok(())
927 }
928 }
929
930 struct TestPlugin {
931 manifest: PluginManifest,
932 fail: bool,
933 unmounted: Arc<AtomicUsize>,
934 }
935
936 struct RegisteringPlugin {
937 manifest: PluginManifest,
938 unmounted: Arc<AtomicUsize>,
939 }
940
941 #[async_trait]
942 impl AgentPlugin for RegisteringPlugin {
943 fn manifest(&self) -> &PluginManifest {
944 &self.manifest
945 }
946 fn lease(&self, _: &PluginMountContext, _: &Value) -> Box<dyn PluginLease> {
947 Box::new(Lease(Arc::clone(&self.unmounted)))
948 }
949 async fn activate(
950 &self,
951 _: &PluginMountContext,
952 _: &Value,
953 registrar: &mut AgentRegistrar<'_>,
954 ) -> Result<(), PluginError> {
955 registrar.tool(Arc::new(TestTool))?;
956 Ok(())
957 }
958 }
959
960 struct RetryLease {
961 id: &'static str,
962 fail_once: Arc<AtomicBool>,
963 calls: Arc<std::sync::Mutex<Vec<&'static str>>>,
964 }
965
966 #[async_trait]
967 impl PluginLease for RetryLease {
968 async fn unmount(&mut self) -> Result<(), PluginError> {
969 self.calls.lock().unwrap().push(self.id);
970 if self.fail_once.swap(false, Ordering::SeqCst) {
971 Err(PluginError::Mount(format!("{} failed", self.id)))
972 } else {
973 Ok(())
974 }
975 }
976 }
977
978 struct CleanupPlugin {
979 manifest: PluginManifest,
980 lease_id: &'static str,
981 fail_activation: bool,
982 fail_once: Arc<AtomicBool>,
983 calls: Arc<std::sync::Mutex<Vec<&'static str>>>,
984 }
985
986 #[async_trait]
987 impl AgentPlugin for CleanupPlugin {
988 fn manifest(&self) -> &PluginManifest {
989 &self.manifest
990 }
991 fn lease(&self, _: &PluginMountContext, _: &Value) -> Box<dyn PluginLease> {
992 Box::new(RetryLease {
993 id: self.lease_id,
994 fail_once: Arc::clone(&self.fail_once),
995 calls: Arc::clone(&self.calls),
996 })
997 }
998 async fn activate(
999 &self,
1000 _: &PluginMountContext,
1001 _: &Value,
1002 _: &mut AgentRegistrar<'_>,
1003 ) -> Result<(), PluginError> {
1004 if self.fail_activation {
1005 Err(PluginError::Mount(self.manifest.id.clone()))
1006 } else {
1007 Ok(())
1008 }
1009 }
1010 }
1011 #[async_trait]
1012 impl AgentPlugin for TestPlugin {
1013 fn manifest(&self) -> &PluginManifest {
1014 &self.manifest
1015 }
1016 fn lease(&self, _: &PluginMountContext, _: &Value) -> Box<dyn PluginLease> {
1017 Box::new(Lease(Arc::clone(&self.unmounted)))
1018 }
1019 async fn activate(
1020 &self,
1021 _context: &PluginMountContext,
1022 _config: &Value,
1023 _registrar: &mut AgentRegistrar<'_>,
1024 ) -> Result<(), PluginError> {
1025 if self.fail {
1026 Err(PluginError::Mount(self.manifest.id.clone()))
1027 } else {
1028 Ok(())
1029 }
1030 }
1031 }
1032
1033 fn plugin(
1034 id: &str,
1035 dependencies: BTreeMap<String, String>,
1036 fail: bool,
1037 unmounted: Arc<AtomicUsize>,
1038 ) -> TestPlugin {
1039 TestPlugin {
1040 manifest: PluginManifest {
1041 id: id.into(),
1042 version: "1.0.0".into(),
1043 dependencies,
1044 config_schema: serde_json::json!({"type":"object"}),
1045 permissions: BTreeSet::new(),
1046 },
1047 fail,
1048 unmounted,
1049 }
1050 }
1051
1052 #[tokio::test]
1053 async fn activation_failure_rolls_back_current_and_prior_leases() {
1054 let unmounted = Arc::new(AtomicUsize::new(0));
1055 let first = plugin("first", BTreeMap::new(), false, Arc::clone(&unmounted));
1056 let second = plugin(
1057 "second",
1058 BTreeMap::from([("first".into(), "^1".into())]),
1059 true,
1060 Arc::clone(&unmounted),
1061 );
1062 let context = PluginMountContext {
1063 tenant_id: "t".parse().unwrap(),
1064 subject_id: "s".parse().unwrap(),
1065 session_id: "session".parse().unwrap(),
1066 profile_revision_id: "profile".parse().unwrap(),
1067 };
1068 assert!(
1069 mount_plugins(&context, &[&second, &first], &BTreeMap::new())
1070 .await
1071 .is_err()
1072 );
1073 assert_eq!(unmounted.load(Ordering::SeqCst), 2);
1074 }
1075
1076 #[tokio::test]
1077 async fn activation_failure_cleans_current_plugin_and_retries_failed_cleanup() {
1078 let calls = Arc::new(std::sync::Mutex::new(Vec::new()));
1079 let first = CleanupPlugin {
1080 manifest: plugin(
1081 "first",
1082 BTreeMap::new(),
1083 false,
1084 Arc::new(AtomicUsize::new(0)),
1085 )
1086 .manifest,
1087 lease_id: "first",
1088 fail_activation: false,
1089 fail_once: Arc::new(AtomicBool::new(false)),
1090 calls: Arc::clone(&calls),
1091 };
1092 let second = CleanupPlugin {
1093 manifest: plugin(
1094 "second",
1095 BTreeMap::from([("first".into(), "^1".into())]),
1096 false,
1097 Arc::new(AtomicUsize::new(0)),
1098 )
1099 .manifest,
1100 lease_id: "second",
1101 fail_activation: true,
1102 fail_once: Arc::new(AtomicBool::new(true)),
1103 calls: Arc::clone(&calls),
1104 };
1105 let context = PluginMountContext {
1106 tenant_id: "t".parse().unwrap(),
1107 subject_id: "s".parse().unwrap(),
1108 session_id: "rollback".parse().unwrap(),
1109 profile_revision_id: "profile".parse().unwrap(),
1110 };
1111
1112 let mut error = mount_plugins(&context, &[&second, &first], &BTreeMap::new())
1113 .await
1114 .unwrap_err();
1115 assert!(matches!(error, PluginError::Rollback { .. }));
1116 assert_eq!(*calls.lock().unwrap(), vec!["second", "first"]);
1117 error.retry_cleanup().await.unwrap();
1118 assert_eq!(*calls.lock().unwrap(), vec!["second", "first", "second"]);
1119 }
1120
1121 #[tokio::test]
1122 async fn registration_conflict_rolls_back_current_and_prior_leases() {
1123 let unmounted = Arc::new(AtomicUsize::new(0));
1124 let manifest = |id: &str, dependencies| PluginManifest {
1125 id: id.into(),
1126 version: "1.0.0".into(),
1127 dependencies,
1128 config_schema: serde_json::json!({"type":"object"}),
1129 permissions: BTreeSet::from([PluginPermission::Tool]),
1130 };
1131 let first = RegisteringPlugin {
1132 manifest: manifest("first", BTreeMap::new()),
1133 unmounted: Arc::clone(&unmounted),
1134 };
1135 let second = RegisteringPlugin {
1136 manifest: manifest("second", BTreeMap::from([("first".into(), "^1".into())])),
1137 unmounted: Arc::clone(&unmounted),
1138 };
1139 let context = PluginMountContext {
1140 tenant_id: "t".parse().unwrap(),
1141 subject_id: "s".parse().unwrap(),
1142 session_id: "session".parse().unwrap(),
1143 profile_revision_id: "profile".parse().unwrap(),
1144 };
1145
1146 assert!(matches!(
1147 mount_plugins(&context, &[&second, &first], &BTreeMap::new()).await,
1148 Err(PluginError::Conflict(_))
1149 ));
1150 assert_eq!(unmounted.load(Ordering::SeqCst), 2);
1151 }
1152
1153 #[tokio::test]
1154 async fn session_lease_unmounts_after_last_runtime_clone() {
1155 let unmounted = Arc::new(AtomicUsize::new(0));
1156 let plugin = plugin("session", BTreeMap::new(), false, Arc::clone(&unmounted));
1157 let context = PluginMountContext {
1158 tenant_id: "t".parse().unwrap(),
1159 subject_id: "s".parse().unwrap(),
1160 session_id: "session".parse().unwrap(),
1161 profile_revision_id: "profile".parse().unwrap(),
1162 };
1163 let mounted = mount_plugins(&context, &[&plugin], &BTreeMap::new())
1164 .await
1165 .unwrap();
1166 let runtime_clone = mounted.clone();
1167 let teardown = tokio::spawn(async move {
1168 let mut mounted = mounted;
1169 mounted.unmount().await
1170 });
1171 tokio::task::yield_now().await;
1172 assert_eq!(unmounted.load(Ordering::SeqCst), 0);
1173 drop(runtime_clone);
1174 teardown.await.unwrap().unwrap();
1175 assert_eq!(unmounted.load(Ordering::SeqCst), 1);
1176 }
1177
1178 #[tokio::test]
1179 async fn cleanup_attempts_every_lease_and_retries_only_failures() {
1180 let calls = Arc::new(std::sync::Mutex::new(Vec::new()));
1181 let first = CleanupPlugin {
1182 manifest: plugin(
1183 "first-cleanup",
1184 BTreeMap::new(),
1185 false,
1186 Arc::new(AtomicUsize::new(0)),
1187 )
1188 .manifest,
1189 lease_id: "first",
1190 fail_activation: false,
1191 fail_once: Arc::new(AtomicBool::new(false)),
1192 calls: Arc::clone(&calls),
1193 };
1194 let second = CleanupPlugin {
1195 manifest: plugin(
1196 "second-cleanup",
1197 BTreeMap::from([("first-cleanup".into(), "^1".into())]),
1198 false,
1199 Arc::new(AtomicUsize::new(0)),
1200 )
1201 .manifest,
1202 lease_id: "second",
1203 fail_activation: false,
1204 fail_once: Arc::new(AtomicBool::new(true)),
1205 calls: Arc::clone(&calls),
1206 };
1207 let context = PluginMountContext {
1208 tenant_id: "t".parse().unwrap(),
1209 subject_id: "s".parse().unwrap(),
1210 session_id: "cleanup".parse().unwrap(),
1211 profile_revision_id: "profile".parse().unwrap(),
1212 };
1213 let mut mounted = mount_plugins(&context, &[&second, &first], &BTreeMap::new())
1214 .await
1215 .unwrap();
1216 assert!(mounted.unmount().await.is_err());
1217 assert_eq!(*calls.lock().unwrap(), vec!["second", "first"]);
1218 mounted.unmount().await.unwrap();
1219 assert_eq!(*calls.lock().unwrap(), vec!["second", "first", "second"]);
1220 }
1221
1222 #[test]
1223 fn dependency_versions_and_cycles_fail_before_mount() {
1224 let counter = Arc::new(AtomicUsize::new(0));
1225 let base = plugin("base", BTreeMap::new(), false, Arc::clone(&counter));
1226 let incompatible = plugin(
1227 "consumer",
1228 BTreeMap::from([("base".into(), "^2".into())]),
1229 false,
1230 Arc::clone(&counter),
1231 );
1232 assert!(matches!(
1233 resolve_plugin_order(&[&base, &incompatible]),
1234 Err(PluginError::DependencyVersion { .. })
1235 ));
1236 let left = plugin(
1237 "left",
1238 BTreeMap::from([("right".into(), "^1".into())]),
1239 false,
1240 Arc::clone(&counter),
1241 );
1242 let right = plugin(
1243 "right",
1244 BTreeMap::from([("left".into(), "^1".into())]),
1245 false,
1246 counter,
1247 );
1248 assert!(matches!(
1249 resolve_plugin_order(&[&left, &right]),
1250 Err(PluginError::DependencyCycle(_))
1251 ));
1252 }
1253
1254 #[test]
1255 fn descriptor_preflight_rejects_missing_and_incompatible_dependencies() {
1256 let descriptors = vec![
1257 PluginDescriptor {
1258 id: "base".into(),
1259 version: "1.0.0".into(),
1260 dependencies: BTreeMap::new(),
1261 permissions: BTreeSet::new(),
1262 },
1263 PluginDescriptor {
1264 id: "child".into(),
1265 version: "1.0.0".into(),
1266 dependencies: BTreeMap::from([("base".into(), "^2".into())]),
1267 permissions: BTreeSet::new(),
1268 },
1269 ];
1270 let references = BTreeMap::from([
1271 ("base".into(), ("1.0.0".into(), Value::Null)),
1272 ("child".into(), ("1.0.0".into(), Value::Null)),
1273 ]);
1274
1275 let issues = validate_plugin_references(&descriptors, &references);
1276 assert_eq!(issues.len(), 1);
1277 assert!(matches!(
1278 issues[0].error,
1279 PluginError::DependencyVersion { .. }
1280 ));
1281 }
1282
1283 #[test]
1284 fn descriptor_preflight_reports_every_missing_dependency() {
1285 let descriptors = vec![PluginDescriptor {
1286 id: "child".into(),
1287 version: "1.0.0".into(),
1288 dependencies: BTreeMap::from([
1289 ("first".into(), "^1".into()),
1290 ("second".into(), "^1".into()),
1291 ]),
1292 permissions: BTreeSet::new(),
1293 }];
1294 let references = BTreeMap::from([("child".into(), ("1.0.0".into(), Value::Null))]);
1295
1296 let issues = validate_plugin_references(&descriptors, &references);
1297
1298 assert_eq!(issues.len(), 2);
1299 assert!(issues.iter().all(|issue| issue.plugin == "child"
1300 && matches!(issue.error, PluginError::MissingDependency(_))));
1301 }
1302
1303 #[tokio::test]
1304 async fn registrar_and_catalog_enforce_manifest_permissions_atomically() {
1305 let manifest = PluginManifest {
1306 id: "test.plugin".into(),
1307 version: "1.0.0".into(),
1308 dependencies: BTreeMap::new(),
1309 config_schema: serde_json::json!({"type":"object","required":["enabled"]}),
1310 permissions: BTreeSet::from([
1311 PluginPermission::Prompt,
1312 PluginPermission::Tool,
1313 PluginPermission::Hook,
1314 ]),
1315 };
1316 manifest.validate().unwrap();
1317 for invalid in [
1318 PluginManifest {
1319 id: "Bad Plugin".into(),
1320 ..manifest.clone()
1321 },
1322 PluginManifest {
1323 version: "latest".into(),
1324 ..manifest.clone()
1325 },
1326 ] {
1327 assert!(matches!(
1328 invalid.validate(),
1329 Err(PluginError::InvalidManifest(_))
1330 ));
1331 }
1332
1333 let mut contributions = PluginContributions::default();
1334 {
1335 let mut registrar = AgentRegistrar {
1336 owner: &manifest,
1337 contributions: &mut contributions,
1338 };
1339 registrar.prompt("system", "prompt").unwrap();
1340 assert!(matches!(
1341 registrar.prompt("system", "again"),
1342 Err(PluginError::Conflict(_))
1343 ));
1344 registrar.tool(Arc::new(TestTool)).unwrap();
1345 assert!(matches!(
1346 registrar.tool(Arc::new(TestTool)),
1347 Err(PluginError::Conflict(_))
1348 ));
1349 registrar.hook(Arc::new(TestHook)).unwrap();
1350 }
1351 let prompt = contributions.prompts.values().next().unwrap();
1352 assert_eq!(prompt.id, "plugin.test.plugin.system");
1353 assert_eq!(prompt.source, "test.plugin");
1354 assert_eq!(prompt.version, "1.0.0");
1355
1356 let denied = PluginManifest {
1357 permissions: BTreeSet::new(),
1358 ..manifest.clone()
1359 };
1360 let mut denied_contributions = PluginContributions::default();
1361 let mut denied_registrar = AgentRegistrar {
1362 owner: &denied,
1363 contributions: &mut denied_contributions,
1364 };
1365 assert!(matches!(
1366 denied_registrar.prompt("x", "x"),
1367 Err(PluginError::Permission(_))
1368 ));
1369 let context = ToolExecutionContext {
1370 request: crate::RequestContext {
1371 tenant_id: "tenant".parse().unwrap(),
1372 subject_id: "subject".parse().unwrap(),
1373 roles: Default::default(),
1374 locale: "en".into(),
1375 request_id: "request".parse().unwrap(),
1376 entitlements: Default::default(),
1377 },
1378 session_id: "session".parse().unwrap(),
1379 run_id: "run".parse().unwrap(),
1380 step: 1,
1381 call_id: "call".parse().unwrap(),
1382 source_event_seq: 1,
1383 interaction_resolution: None,
1384 cancellation: crate::CancellationToken::default(),
1385 deadline: std::time::Instant::now() + std::time::Duration::from_secs(15),
1386 };
1387 assert_eq!(
1388 TestHook
1389 .before_tool(&context, "test.tool", &serde_json::json!({}))
1390 .await,
1391 HookDecision::Continue
1392 );
1393 TestHook
1394 .after_tool(
1395 &context,
1396 "test.tool",
1397 &serde_json::json!({}),
1398 &serde_json::json!({}),
1399 )
1400 .await
1401 .unwrap();
1402 let unmounted = Arc::new(AtomicUsize::new(0));
1403 let plugin = Arc::new(TestPlugin {
1404 manifest: manifest.clone(),
1405 fail: false,
1406 unmounted: Arc::clone(&unmounted),
1407 });
1408 let mut catalog = PluginCatalog::default();
1409 catalog.register(plugin.clone()).unwrap();
1410 assert!(matches!(
1411 catalog.register(plugin),
1412 Err(PluginError::Conflict(_))
1413 ));
1414 let mount_context = PluginMountContext {
1415 tenant_id: "t".parse().unwrap(),
1416 subject_id: "s".parse().unwrap(),
1417 session_id: "session".parse().unwrap(),
1418 profile_revision_id: "profile".parse().unwrap(),
1419 };
1420 assert!(matches!(
1421 catalog
1422 .mount(
1423 &mount_context,
1424 &BTreeMap::from([("missing".into(), ("1.0.0".into(), serde_json::json!({})))])
1425 )
1426 .await,
1427 Err(PluginError::MissingDependency(_))
1428 ));
1429 assert!(matches!(
1430 catalog
1431 .mount(
1432 &mount_context,
1433 &BTreeMap::from([(
1434 "test.plugin".into(),
1435 ("2.0.0".into(), serde_json::json!({}))
1436 )])
1437 )
1438 .await,
1439 Err(PluginError::DependencyVersion { .. })
1440 ));
1441 assert!(matches!(
1442 catalog
1443 .mount(
1444 &mount_context,
1445 &BTreeMap::from([(
1446 "test.plugin".into(),
1447 ("1.0.0".into(), serde_json::json!({}))
1448 )])
1449 )
1450 .await,
1451 Err(PluginError::InvalidConfig(_, _))
1452 ));
1453 let mut mounted = catalog
1454 .mount(
1455 &mount_context,
1456 &BTreeMap::from([(
1457 "test.plugin".into(),
1458 ("1.0.0".into(), serde_json::json!({"enabled":true})),
1459 )]),
1460 )
1461 .await
1462 .unwrap();
1463 mounted.unmount().await.unwrap();
1464 assert_eq!(unmounted.load(Ordering::SeqCst), 1);
1465 }
1466}