Skip to main content

Extension Discovery

ExtensionRegistry::discover() collects inventory registrations, instantiates factories, validates dependencies, and sorts topologically with cycle detection.

At startup, ExtensionRegistry::discover() collects all registered extensions, validates them, and sorts them for loading.

Discovery Process

let registry = ExtensionRegistry::discover()?;

This performs:

  1. Collection - Iterate inventory::iter::<ExtensionRegistration>
  2. Instantiation - Call each factory to create extension instances
  3. Merging - Include any runtime-injected extensions not already discovered
  4. Sorting - Topological order by dependencies, priority as tie-break (returns LoaderError::DependencyCycle on a cycle)

Dependency validation is a separate step: registry.validate() (called by discover_and_merge) checks that every declared dependency is registered.

ExtensionRegistry

pub struct ExtensionRegistry {
    extensions: HashMap<String, Arc<dyn Extension>>,
    sorted_extensions: Vec<Arc<dyn Extension>>,
}

impl ExtensionRegistry {
    pub fn discover() -> Result<Self, LoaderError>;
    pub fn discover_and_merge(injected: Vec<Arc<dyn Extension>>) -> Result<Self, LoaderError>;
    pub fn get(&self, id: &str) -> Option<&Arc<dyn Extension>>;
    pub fn extensions(&self) -> &[Arc<dyn Extension>];
    pub fn validate(&self) -> Result<(), LoaderError>;
}

Sorting

Extensions are ordered topologically by Extension::dependencies(), with Extension::priority() breaking ties (lower values first). A dependency declared but not loaded in the build is warned and ignored for ordering; a cycle fails discovery with LoaderError::DependencyCycle and a human-readable chain ("A -> B -> A").

Typical priorities:

  • 1-10 - Core infrastructure (database, users)
  • 10-50 - Domain extensions
  • 50-100 - Feature extensions
  • 100+ - Optional/plugin extensions

Validation

Dependency Validation

pub fn validate_dependencies(&self) -> Result<(), LoaderError> {
    for ext in self.extensions.values() {
        for dep_id in ext.dependencies() {
            if !self.extensions.contains_key(dep_id) {
                return Err(LoaderError::MissingDependency {
                    extension: ext.id().to_owned(),
                    dependency: dep_id.to_owned(),
                });
            }
        }
    }

    let ids: Vec<String> = self.extensions.keys().cloned().collect();
    topo_sort(&ids, &self.extensions).map(|_| ())
}

Cycle Detection

Cycle detection is part of the topological sort: both the sorting done during discovery and validate_dependencies() run topo_sort, which returns LoaderError::DependencyCycle with the offending chain when a cycle exists.

Path Validation

API base paths must start with /api/ and must not collide with reserved path prefixes:

pub const RESERVED_PATHS: &[&str] = &[
    "/api/v1/oauth",
    "/api/v1/users",
    "/api/v1/agents",
    // ...
];

pub fn validate_api_paths(&self, ctx: &dyn ExtensionContext) -> Result<(), LoaderError> {
    for ext in self.extensions.values() {
        if let Some(router_config) = ext.router(ctx) {
            let base_path = router_config.base_path;

            if !base_path.starts_with("/api/") {
                return Err(LoaderError::InvalidBasePath {
                    extension: ext.id().to_owned(),
                    path: base_path.to_owned(),
                });
            }

            for reserved in RESERVED_PATHS {
                if base_path.starts_with(reserved) {
                    return Err(LoaderError::ReservedPathCollision {
                        extension: ext.id().to_owned(),
                        path: base_path.to_owned(),
                    });
                }
            }
        }
    }
    Ok(())
}

Filtering

Get extensions with specific capabilities:

// Extensions with schemas
let schema_exts = registry.schema_extensions();

// Extensions with jobs
let job_exts = registry.job_extensions();

// Extensions with routers (needs an ExtensionContext)
let api_exts = registry.api_extensions(&ctx);

Runtime Injection

Extensions can be injected programmatically:

use systemprompt::extension::runtime_config::{set_injected_extensions, InjectedExtensions};

let injected = InjectedExtensions {
    extensions: vec![Arc::new(TestExtension)],
    ..InjectedExtensions::default()
};

set_injected_extensions(injected).expect("injected extensions already set");

set_injected_extensions uses a process-wide OnceLock, so it can only be called once. Injected extensions are merged during discovery; ids already discovered via inventory win. InjectedExtensions also carries a web_assets: WebAssetsStrategy field for hosts that cannot rely on linker-based asset registration.

Debugging

List Extensions

systemprompt plugins list

Show Extension Details

systemprompt plugins show <id>

Validate

systemprompt plugins validate --verbose

Common Errors

MissingDependency:

Extension 'my-ext' requires dependency 'users' which is not registered

Fix: Ensure dependency is linked and registered.

DependencyCycle:

Dependency cycle detected while ordering extensions: a -> b -> c -> a

Fix: Break the cycle by restructuring dependencies.

ReservedPathCollision:

Extension 'my-ext' uses reserved API path '/api/v1/users'

Fix: Use a different base path.