use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
use semver::Version;
use nexo_config::AgentsConfig;
use std::collections::BTreeSet;
use super::capability_aggregator::{aggregate_plugin_gates, AggregatedGate, UnmetRequirement};
use super::factory::PluginFactoryRegistry;
use super::init_loop::run_plugin_init_loop_with_factory;
use super::{
discover, merge_plugin_contributed_agents, merge_plugin_contributed_skills,
run_plugin_init_loop, NexoPluginRegistry, NexoPluginRegistrySnapshot, PluginDiscoveryConfig,
};
use crate::agent::channel_adapter::ChannelAdapterRegistry;
use crate::agent::plugin_host::NexoPlugin;
use crate::config_reload::ConfigReloadCoordinator;
use nexo_broker::AnyBroker;
use tokio_util::sync::CancellationToken;
pub struct SubprocessRuntime {
pub broker: AnyBroker,
pub shutdown: CancellationToken,
pub config_dir: PathBuf,
pub state_root: PathBuf,
pub long_term_memory: Option<Arc<nexo_memory::LongTermMemory>>,
pub llm_registry: Arc<nexo_llm::LlmRegistry>,
pub llm_config: Arc<nexo_config::LlmConfig>,
pub sandbox: Arc<crate::agent::plugin_sandbox::SandboxRunner>,
}
pub struct WirePluginRegistryOutput {
pub registry: Arc<NexoPluginRegistry>,
pub skill_roots: Vec<PathBuf>,
pub channel_adapter_registry: Arc<ChannelAdapterRegistry>,
pub hook_registry: Arc<crate::agent::hook_registry::HookRegistry>,
pub vector_backend_registry: Arc<crate::agent::vector_backend_registry::VectorBackendRegistry>,
pub tool_registry: Arc<crate::agent::tool_registry::ToolRegistry>,
pub plugin_capability_gates: std::collections::BTreeMap<String, AggregatedGate>,
pub unmet_required_capabilities: Vec<UnmetRequirement>,
pub plugin_handles: BTreeMap<String, Arc<dyn NexoPlugin>>,
}
pub async fn wire_plugin_registry(
cfg: &mut AgentsConfig,
discovery_cfg: &PluginDiscoveryConfig,
current_version: &Version,
core_env_vars: &[(&str, &str)],
available_capabilities: &BTreeSet<String>,
factory_registry: Option<&PluginFactoryRegistry>,
) -> WirePluginRegistryOutput {
wire_plugin_registry_with_runtime(
cfg,
discovery_cfg,
current_version,
core_env_vars,
available_capabilities,
factory_registry,
None,
&[],
)
.await
}
pub async fn wire_plugin_registry_with_runtime(
cfg: &mut AgentsConfig,
discovery_cfg: &PluginDiscoveryConfig,
current_version: &Version,
core_env_vars: &[(&str, &str)],
available_capabilities: &BTreeSet<String>,
factory_registry: Option<&PluginFactoryRegistry>,
subprocess_runtime: Option<&SubprocessRuntime>,
extra_plugins: &[super::DiscoveredPlugin],
) -> WirePluginRegistryOutput {
let snap = discover(discovery_cfg, current_version);
let snap: Arc<super::NexoPluginRegistrySnapshot> = if extra_plugins.is_empty() {
snap
} else {
let mut owned: super::NexoPluginRegistrySnapshot = (*snap).clone();
owned.plugins.extend(extra_plugins.iter().cloned());
Arc::new(owned)
};
let agent_merge = merge_plugin_contributed_agents(&snap, cfg);
let skill_merge = merge_plugin_contributed_skills(&snap);
let skill_roots: Vec<PathBuf> = skill_merge.skill_roots.values().cloned().collect();
let shared_channel_adapter_registry: Arc<ChannelAdapterRegistry> =
Arc::new(ChannelAdapterRegistry::new());
let shared_hook_registry: Arc<crate::agent::hook_registry::HookRegistry> =
Arc::new(crate::agent::hook_registry::HookRegistry::new());
let shared_vector_backend_registry: Arc<
crate::agent::vector_backend_registry::VectorBackendRegistry,
> = Arc::new(crate::agent::vector_backend_registry::VectorBackendRegistry::new());
let shared_tool_registry: Arc<crate::agent::tool_registry::ToolRegistry> =
Arc::new(crate::agent::tool_registry::ToolRegistry::new());
let (init_outcomes, plugin_handles): (
BTreeMap<String, super::InitOutcome>,
BTreeMap<String, Arc<dyn NexoPlugin>>,
) = match (factory_registry, subprocess_runtime) {
(Some(factory), Some(rt)) => {
let stubs = SubprocessCtxStubs::build_with_shared_registries(
rt,
shared_channel_adapter_registry.clone(),
shared_hook_registry.clone(),
shared_vector_backend_registry.clone(),
shared_tool_registry.clone(),
);
let r = run_plugin_init_loop_with_factory(
&snap,
factory,
rt.config_dir.as_path(),
&shared_channel_adapter_registry,
&rt.llm_registry,
&shared_hook_registry,
&shared_vector_backend_registry,
None, |manifest, plugin_cfg| stubs.context_for(manifest, rt, plugin_cfg),
)
.await;
(r.outcomes, r.handles)
}
(Some(factory), None) => {
let legacy_cfg_dir = std::path::Path::new(".");
let legacy_llm_registry: Arc<nexo_llm::LlmRegistry> =
Arc::new(nexo_llm::LlmRegistry::new());
let r = run_plugin_init_loop_with_factory(
&snap,
factory,
legacy_cfg_dir,
&shared_channel_adapter_registry,
&legacy_llm_registry,
&shared_hook_registry,
&shared_vector_backend_registry,
None, |_manifest, _plugin_cfg| -> crate::agent::plugin_host::PluginInitContext<'_> {
unreachable!(
"wire_plugin_registry: subprocess_runtime is None but a manifest with entrypoint was discovered. \
Use wire_plugin_registry_with_runtime(...subprocess_runtime: Some(_)) when subprocess plugins might be present."
)
},
)
.await;
(r.outcomes, r.handles)
}
(None, _) => {
let empty_handles: BTreeMap<String, Arc<dyn NexoPlugin>> = BTreeMap::new();
let outcomes = run_plugin_init_loop(
&snap,
&empty_handles,
|_manifest, _plugin_cfg| -> crate::agent::plugin_host::PluginInitContext<'_> {
unreachable!(
"wire_plugin_registry passes empty handles; ctx_factory must not be invoked"
)
},
)
.await;
(outcomes, BTreeMap::new())
}
};
let mut updated_report = snap.last_report.clone();
updated_report.fold_agent_merge(agent_merge);
updated_report.fold_skill_merge(skill_merge);
updated_report.fold_init_outcomes(init_outcomes);
let aggregator_snap_view = NexoPluginRegistrySnapshot {
plugins: snap.plugins.clone(),
last_report: super::report::PluginDiscoveryReport::default(),
skill_roots: BTreeMap::new(),
};
let aggregation =
aggregate_plugin_gates(&aggregator_snap_view, core_env_vars, available_capabilities);
let plugin_capability_gates_for_output = aggregation.gates.clone();
let unmet_required_for_output = aggregation.unmet_required.clone();
updated_report.fold_capability_aggregation(aggregation);
let new_skill_roots_for_snapshot: BTreeMap<String, PathBuf> = snap
.plugins
.iter()
.filter_map(|p| {
updated_report
.contributed_skills_per_plugin
.get(&p.manifest.plugin.id)
.map(|_| {
let dir = p.root_dir.join(
p.manifest
.plugin
.skills
.contributes_dir
.clone()
.unwrap_or_default(),
);
(p.manifest.plugin.id.clone(), dir)
})
})
.collect();
let final_snap = Arc::new(NexoPluginRegistrySnapshot {
plugins: snap.plugins.clone(),
last_report: updated_report,
skill_roots: new_skill_roots_for_snapshot,
});
let registry = NexoPluginRegistry::empty();
registry.swap(final_snap.clone());
let report = &final_snap.last_report;
let contributed_agents_total: usize = report
.contributed_agents_per_plugin
.values()
.map(|v| v.len())
.sum();
let contributed_skills_total: usize = report
.contributed_skills_per_plugin
.values()
.map(|v| v.len())
.sum();
let init_failed_total: usize = report
.init_outcomes
.values()
.filter(|o| matches!(o, super::InitOutcome::Failed { .. }))
.count();
tracing::info!(
target: "plugins.discovery",
loaded = report.loaded_ids.len(),
invalid = report.invalid,
disabled = report.disabled,
duplicates = report.duplicates,
contributed_agents_total = contributed_agents_total,
contributed_skills_total = contributed_skills_total,
merge_conflicts_total = report.agent_merge_conflicts.len()
+ report.skill_conflicts.len(),
init_failed_total = init_failed_total,
"plugin registry wire complete"
);
for diag in &report.diagnostics {
match diag.level {
super::DiagnosticLevel::Error => {
tracing::warn!(
target: "plugins.discovery",
path = %diag.path.display(),
kind = ?diag.kind,
"plugin discovery diagnostic (ERROR — plugin rejected)"
);
}
super::DiagnosticLevel::Warn => {
tracing::warn!(
target: "plugins.discovery",
path = %diag.path.display(),
kind = ?diag.kind,
"plugin discovery diagnostic (warn)"
);
}
}
}
WirePluginRegistryOutput {
registry,
skill_roots,
channel_adapter_registry: shared_channel_adapter_registry,
hook_registry: shared_hook_registry,
vector_backend_registry: shared_vector_backend_registry,
tool_registry: shared_tool_registry,
plugin_capability_gates: plugin_capability_gates_for_output,
unmet_required_capabilities: unmet_required_for_output,
plugin_handles,
}
}
struct SubprocessCtxStubs {
tool_registry: Arc<crate::agent::tool_registry::ToolRegistry>,
advisor_registry: Arc<tokio::sync::RwLock<nexo_driver_permission::AdvisorRegistry>>,
hook_registry: Arc<crate::agent::hook_registry::HookRegistry>,
reload_coord: Arc<ConfigReloadCoordinator>,
sessions: Arc<crate::session::SessionManager>,
channel_adapter_registry: Arc<ChannelAdapterRegistry>,
#[allow(dead_code)]
vector_backend_registry: Arc<crate::agent::vector_backend_registry::VectorBackendRegistry>,
}
impl SubprocessCtxStubs {
fn build_with_shared_registries(
rt: &SubprocessRuntime,
channel_adapter_registry: Arc<ChannelAdapterRegistry>,
hook_registry: Arc<crate::agent::hook_registry::HookRegistry>,
vector_backend_registry: Arc<crate::agent::vector_backend_registry::VectorBackendRegistry>,
tool_registry: Arc<crate::agent::tool_registry::ToolRegistry>,
) -> Self {
let mut stubs = Self::build(rt);
stubs.channel_adapter_registry = channel_adapter_registry;
stubs.hook_registry = hook_registry;
stubs.vector_backend_registry = vector_backend_registry;
stubs.tool_registry = tool_registry;
stubs
}
fn build(rt: &SubprocessRuntime) -> Self {
let reload_coord = Arc::new(ConfigReloadCoordinator::new(
rt.config_dir.clone(),
rt.llm_registry.clone(),
rt.shutdown.clone(),
));
Self {
tool_registry: Arc::new(crate::agent::tool_registry::ToolRegistry::new()),
advisor_registry: Arc::new(tokio::sync::RwLock::new(
nexo_driver_permission::AdvisorRegistry::new(),
)),
hook_registry: Arc::new(crate::agent::hook_registry::HookRegistry::new()),
reload_coord,
sessions: Arc::new(crate::session::SessionManager::new(
std::time::Duration::from_secs(60),
8,
)),
channel_adapter_registry: Arc::new(ChannelAdapterRegistry::new()),
vector_backend_registry: Arc::new(
crate::agent::vector_backend_registry::VectorBackendRegistry::new(),
),
}
}
fn context_for<'env>(
&'env self,
manifest: &nexo_plugin_manifest::PluginManifest,
rt: &'env SubprocessRuntime,
plugin_config: &Arc<serde_yaml::Value>,
) -> crate::agent::plugin_host::PluginInitContext<'env> {
let scoped = Arc::new(crate::agent::scoped_tool_registry::ScopedToolRegistry::new(
manifest.plugin.id.clone(),
&manifest.plugin.tools.expose,
self.tool_registry.clone(),
crate::agent::scoped_tool_registry::NamespaceEnforcement::from_env(),
Some(rt.broker.clone()),
));
crate::agent::plugin_host::PluginInitContext {
config_dir: rt.config_dir.as_path(),
state_root: rt.state_root.as_path(),
tool_registry: scoped,
advisor_registry: self.advisor_registry.clone(),
hook_registry: self.hook_registry.clone(),
broker: rt.broker.clone(),
llm_registry: rt.llm_registry.clone(),
llm_config: rt.llm_config.clone(),
reload_coord: self.reload_coord.clone(),
sessions: self.sessions.clone(),
long_term_memory: rt.long_term_memory.clone(),
shutdown: rt.shutdown.clone(),
channel_adapter_registry: self.channel_adapter_registry.clone(),
plugin_config: plugin_config.clone(),
sandbox: rt.sandbox.clone(),
}
}
}
pub async fn register_plugin_registry_reload_hook(
coord: Arc<ConfigReloadCoordinator>,
registry: Arc<NexoPluginRegistry>,
discovery_cfg: PluginDiscoveryConfig,
current_version: semver::Version,
) {
coord
.register_post_hook(Box::new(move || {
let discovered = discover(&discovery_cfg, ¤t_version);
let prev = registry.snapshot();
let prev_loaded = prev.last_report.loaded_ids.len();
let prev_invalid = prev.last_report.invalid;
let new_loaded = discovered.last_report.loaded_ids.len();
let new_invalid = discovered.last_report.invalid;
let new_snap = Arc::new(NexoPluginRegistrySnapshot {
plugins: discovered.plugins.clone(),
last_report: discovered.last_report.clone(),
skill_roots: std::collections::BTreeMap::new(),
});
registry.swap(new_snap);
tracing::info!(
target: "plugins.discovery",
prev_loaded,
new_loaded,
delta_loaded = (new_loaded as i64) - (prev_loaded as i64),
prev_invalid,
new_invalid,
delta_invalid = (new_invalid as i64) - (prev_invalid as i64),
"post-reload plugin registry swap complete"
);
}))
.await;
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::sync::Arc;
use semver::Version;
use tokio_util::sync::CancellationToken;
use crate::config_reload::ConfigReloadCoordinator;
use nexo_llm::LlmRegistry;
fn fresh_coord() -> Arc<ConfigReloadCoordinator> {
let tmp = tempfile::tempdir().unwrap();
Arc::new(ConfigReloadCoordinator::new(
tmp.path().to_path_buf(),
Arc::new(LlmRegistry::new()),
CancellationToken::new(),
))
}
fn write_plugin(root: &std::path::Path, plugin_id: &str) {
std::fs::create_dir_all(root).unwrap();
let manifest = format!(
"[plugin]\n\
id = \"{plugin_id}\"\n\
version = \"0.1.0\"\n\
name = \"{plugin_id}\"\n\
description = \"hot-reload fixture\"\n\
min_nexo_version = \">=0.0.1\"\n",
);
std::fs::write(root.join("nexo-plugin.toml"), manifest).unwrap();
}
#[tokio::test]
async fn register_plugin_registry_reload_hook_pushes_one_post_hook() {
let coord = fresh_coord();
let registry = NexoPluginRegistry::empty();
assert_eq!(coord.post_hooks_len_for_test().await, 0);
register_plugin_registry_reload_hook(
Arc::clone(&coord),
registry,
PluginDiscoveryConfig::default(),
Version::new(0, 1, 0),
)
.await;
assert_eq!(coord.post_hooks_len_for_test().await, 1);
}
#[tokio::test]
async fn hook_replaces_snapshot_when_discover_succeeds() {
let tmp = tempfile::tempdir().unwrap();
write_plugin(&tmp.path().join("alpha"), "alpha");
let coord = fresh_coord();
let registry = NexoPluginRegistry::empty();
assert!(registry.snapshot().last_report.loaded_ids.is_empty());
let cfg = PluginDiscoveryConfig {
search_paths: vec![tmp.path().to_path_buf()],
..Default::default()
};
register_plugin_registry_reload_hook(
Arc::clone(&coord),
Arc::clone(®istry),
cfg,
Version::new(0, 1, 0),
)
.await;
coord.fire_post_hooks_for_test().await;
let snap = registry.snapshot();
assert_eq!(snap.last_report.loaded_ids, vec!["alpha".to_string()]);
assert!(snap.skill_roots.is_empty());
}
#[tokio::test]
async fn hook_swallows_discover_failure_does_not_panic() {
let coord = fresh_coord();
let registry = NexoPluginRegistry::empty();
let cfg = PluginDiscoveryConfig {
search_paths: vec![PathBuf::from(
"/this/path/definitely/does/not/exist/__nexo_test__",
)],
..Default::default()
};
register_plugin_registry_reload_hook(
Arc::clone(&coord),
Arc::clone(®istry),
cfg,
Version::new(0, 1, 0),
)
.await;
coord.fire_post_hooks_for_test().await;
let snap = registry.snapshot();
assert!(snap.last_report.loaded_ids.is_empty());
assert!(!snap.last_report.diagnostics.is_empty());
}
}