use std::collections::BTreeMap;
use std::path::Path;
use std::sync::Arc;
use std::time::Instant;
use serde::Serialize;
use nexo_plugin_manifest::PluginManifest;
use serde_yaml::Value;
use crate::agent::plugin_config_loader::{config_error_kind, load_plugin_config};
use crate::agent::plugin_host::{NexoPlugin, PluginInitContext};
use crate::agent::scoped_tool_registry::{NamespaceEnforcement, NamespaceViolation};
use super::factory::{FactoryInstantiateError, PluginFactoryRegistry};
use super::subprocess::subprocess_plugin_factory;
use super::NexoPluginRegistrySnapshot;
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "outcome", rename_all = "snake_case")]
pub enum InitOutcome {
Ok {
duration_ms: u64,
},
Failed {
error: String,
},
NoHandle,
}
pub async fn run_plugin_init_loop<'env, F>(
snapshot: &NexoPluginRegistrySnapshot,
handles: &BTreeMap<String, Arc<dyn NexoPlugin>>,
mut ctx_factory: F,
) -> BTreeMap<String, InitOutcome>
where
F: FnMut(&PluginManifest, &Arc<Value>) -> PluginInitContext<'env>,
{
let mut outcomes = BTreeMap::new();
for plugin in &snapshot.plugins {
let id = plugin.manifest.plugin.id.clone();
let Some(handle) = handles.get(&id).cloned() else {
outcomes.insert(id, InitOutcome::NoHandle);
continue;
};
let empty_cfg: Arc<Value> = Arc::new(Value::Mapping(serde_yaml::Mapping::new()));
let mut ctx = ctx_factory(&plugin.manifest, &empty_cfg);
let start = Instant::now();
match handle.init(&mut ctx).await {
Ok(()) => {
let duration_ms = start.elapsed().as_millis() as u64;
outcomes.insert(id, InitOutcome::Ok { duration_ms });
}
Err(e) => {
let error = e.to_string();
tracing::warn!(
target: "plugins.init",
plugin_id = %id,
error = %error,
"plugin init failed; continuing"
);
outcomes.insert(id, InitOutcome::Failed { error });
}
}
}
outcomes
}
pub struct FactoryInitResult {
pub outcomes: BTreeMap<String, InitOutcome>,
pub handles: BTreeMap<String, Arc<dyn NexoPlugin>>,
}
fn format_violation_sample(violations: &[NamespaceViolation]) -> String {
let take = violations.len().min(3);
let head: Vec<String> = violations
.iter()
.take(take)
.map(|v| format!("{}={}", v.attempted_name, v.reason.as_str()))
.collect();
if violations.len() > take {
format!("{} … (+{} more)", head.join(", "), violations.len() - take)
} else {
head.join(", ")
}
}
fn try_load_plugin_config(
plugin_id: &str,
plugin_root: &Path,
config_dir: &Path,
manifest: &PluginManifest,
) -> Result<Arc<Value>, InitOutcome> {
match load_plugin_config(plugin_root, config_dir, manifest) {
Ok(cfg) => Ok(Arc::new(cfg.merged)),
Err(err) => {
let kind = config_error_kind(&err);
let error = err.to_string();
tracing::warn!(
target: "plugins.init",
plugin_id = %plugin_id,
kind = %kind,
%error,
"plugin config load failed; skipping init"
);
Err(InitOutcome::Failed {
error: format!("config load: {error}"),
})
}
}
}
async fn register_remote_vector_backends_after_init(
plugin_id: &str,
handle: &Arc<dyn NexoPlugin>,
vector_backend_registry: &Arc<crate::agent::vector_backend_registry::VectorBackendRegistry>,
) -> Option<InitOutcome> {
let any = handle.as_any();
let sub = match any
.downcast_ref::<crate::agent::nexo_plugin_registry::subprocess::SubprocessNexoPlugin>()
{
Some(s) => s,
None => return None,
};
match sub
.register_remote_vector_backends(vector_backend_registry)
.await
{
Ok(_) => None,
Err(e) => {
let error = format!("vector backend register: {e}");
tracing::warn!(
target: "plugins.init",
plugin_id = %plugin_id,
%error,
"remote vector backend registration failed"
);
Some(InitOutcome::Failed { error })
}
}
}
async fn register_remote_tool_handlers_after_init(
plugin_id: &str,
handle: &Arc<dyn NexoPlugin>,
scoped_tool_registry: &Arc<crate::agent::scoped_tool_registry::ScopedToolRegistry>,
) -> Option<InitOutcome> {
let any = handle.as_any();
let sub = match any
.downcast_ref::<crate::agent::nexo_plugin_registry::subprocess::SubprocessNexoPlugin>()
{
Some(s) => s,
None => return None,
};
match sub
.register_remote_tool_handlers(scoped_tool_registry)
.await
{
Ok(names) => {
if !names.is_empty() {
tracing::info!(
target: "plugins.init",
plugin_id = %plugin_id,
registered_count = names.len(),
"registered remote tools"
);
}
None
}
Err(e) => {
let error = format!("tool handler register: {e}");
tracing::warn!(
target: "plugins.init",
plugin_id = %plugin_id,
%error,
"remote tool handler registration failed"
);
Some(InitOutcome::Failed { error })
}
}
}
async fn register_remote_hook_handlers_after_init(
plugin_id: &str,
handle: &Arc<dyn NexoPlugin>,
hook_registry: &Arc<crate::agent::hook_registry::HookRegistry>,
) -> Option<InitOutcome> {
let any = handle.as_any();
let sub = match any
.downcast_ref::<crate::agent::nexo_plugin_registry::subprocess::SubprocessNexoPlugin>()
{
Some(s) => s,
None => return None,
};
match sub.register_remote_hook_handlers(hook_registry).await {
Ok(_) => None,
Err(e) => {
let error = format!("hook handler register: {e}");
tracing::warn!(
target: "plugins.init",
plugin_id = %plugin_id,
%error,
"remote hook handler registration failed"
);
Some(InitOutcome::Failed { error })
}
}
}
async fn register_remote_llm_providers_after_init(
plugin_id: &str,
handle: &Arc<dyn NexoPlugin>,
llm_registry: &Arc<nexo_llm::LlmRegistry>,
) -> Option<InitOutcome> {
let any = handle.as_any();
let sub = match any
.downcast_ref::<crate::agent::nexo_plugin_registry::subprocess::SubprocessNexoPlugin>()
{
Some(s) => s,
None => return None,
};
match sub.register_remote_llm_providers(llm_registry).await {
Ok(_) => None,
Err(e) => {
let error = format!("llm provider register: {e}");
tracing::warn!(
target: "plugins.init",
plugin_id = %plugin_id,
%error,
"remote LLM provider registration failed"
);
Some(InitOutcome::Failed { error })
}
}
}
async fn register_remote_channels_after_init(
plugin_id: &str,
handle: &Arc<dyn NexoPlugin>,
channel_adapter_registry: &Arc<crate::agent::channel_adapter::ChannelAdapterRegistry>,
) -> Option<InitOutcome> {
let any = handle.as_any();
let sub = match any
.downcast_ref::<crate::agent::nexo_plugin_registry::subprocess::SubprocessNexoPlugin>()
{
Some(s) => s,
None => return None,
};
match sub
.register_remote_channel_adapters(channel_adapter_registry)
.await
{
Ok(_) => None,
Err(e) => {
let error = format!("channel adapter register: {e}");
tracing::warn!(
target: "plugins.init",
plugin_id = %plugin_id,
%error,
"remote channel adapter registration failed"
);
Some(InitOutcome::Failed { error })
}
}
}
fn start_plugin_supervisor_loop_after_init(
plugin_id: &str,
handle: &Arc<dyn NexoPlugin>,
ctx: &PluginInitContext<'_>,
) -> Option<InitOutcome> {
let any = handle.as_any();
let sub = match any
.downcast_ref::<crate::agent::nexo_plugin_registry::subprocess::SubprocessNexoPlugin>()
{
Some(s) => s,
None => return None,
};
let llm = Some(crate::agent::nexo_plugin_registry::subprocess::LlmServices {
registry: ctx.llm_registry.clone(),
config: ctx.llm_config.clone(),
});
let Some(arc_sub) = sub.weak_self_arc() else {
tracing::debug!(
target: "plugins.init",
plugin_id = %plugin_id,
"supervisor loop skipped (no Weak<Self> populated; factory bypassed)"
);
return None;
};
arc_sub.spawn_supervisor_loop(
ctx.shutdown.clone(),
Some(ctx.broker.clone()),
ctx.long_term_memory.clone(),
llm,
);
tracing::debug!(
target: "plugins.init",
plugin_id = %plugin_id,
"supervisor loop spawned (auto-respawn per manifest.supervisor.respawn)"
);
None
}
fn check_namespace_after_init(plugin_id: &str, ctx: &PluginInitContext<'_>) -> Option<InitOutcome> {
let violations = ctx.tool_registry.drain_violations();
if violations.is_empty() {
return None;
}
if ctx.tool_registry.mode() != NamespaceEnforcement::Strict {
tracing::warn!(
target: "plugins.init",
plugin_id = %plugin_id,
count = violations.len(),
"plugin tool-namespace violations recorded (warn mode; init succeeded)",
);
return None;
}
let sample = format_violation_sample(&violations);
let error = format!(
"plugin `{plugin_id}` violated tool namespace policy ({} violation(s); first 3: {sample})",
violations.len()
);
tracing::warn!(
target: "plugins.init",
plugin_id = %plugin_id,
count = violations.len(),
%sample,
"tool namespace violations rejected (strict mode)",
);
Some(InitOutcome::Failed { error })
}
pub async fn run_plugin_init_loop_with_factory<'env, F>(
snapshot: &NexoPluginRegistrySnapshot,
factory_registry: &PluginFactoryRegistry,
config_dir: &Path,
channel_adapter_registry: &Arc<crate::agent::channel_adapter::ChannelAdapterRegistry>,
llm_registry: &Arc<nexo_llm::LlmRegistry>,
hook_registry: &Arc<crate::agent::hook_registry::HookRegistry>,
vector_backend_registry: &Arc<crate::agent::vector_backend_registry::VectorBackendRegistry>,
mut ctx_factory: F,
) -> FactoryInitResult
where
F: FnMut(&PluginManifest, &Arc<Value>) -> PluginInitContext<'env>,
{
let mut outcomes = BTreeMap::new();
let mut handles: BTreeMap<String, Arc<dyn NexoPlugin>> = BTreeMap::new();
for plugin in &snapshot.plugins {
let id = plugin.manifest.plugin.id.clone();
let plugin_cfg =
match try_load_plugin_config(&id, &plugin.root_dir, config_dir, &plugin.manifest) {
Ok(cfg) => cfg,
Err(failed) => {
outcomes.insert(id, failed);
continue;
}
};
if !factory_registry.is_registered(&id) {
if plugin.manifest.plugin.entrypoint.is_subprocess() {
let auto_factory = subprocess_plugin_factory(plugin.manifest.clone());
match auto_factory(&plugin.manifest) {
Ok(handle) => {
let mut ctx = ctx_factory(&plugin.manifest, &plugin_cfg);
let start = std::time::Instant::now();
match handle.init(&mut ctx).await {
Ok(()) => {
let duration_ms = start.elapsed().as_millis() as u64;
if let Some(failed) = check_namespace_after_init(&id, &ctx) {
outcomes.insert(id, failed);
} else if let Some(failed) = register_remote_channels_after_init(
&id,
&handle,
channel_adapter_registry,
)
.await
{
outcomes.insert(id, failed);
} else if let Some(failed) =
register_remote_llm_providers_after_init(
&id,
&handle,
llm_registry,
)
.await
{
outcomes.insert(id, failed);
} else if let Some(failed) =
register_remote_hook_handlers_after_init(
&id,
&handle,
hook_registry,
)
.await
{
outcomes.insert(id, failed);
} else if let Some(failed) =
register_remote_vector_backends_after_init(
&id,
&handle,
vector_backend_registry,
)
.await
{
outcomes.insert(id, failed);
} else if let Some(failed) =
register_remote_tool_handlers_after_init(
&id,
&handle,
&ctx.tool_registry,
)
.await
{
outcomes.insert(id, failed);
} else if let Some(failed) =
start_plugin_supervisor_loop_after_init(
&id, &handle, &ctx,
)
{
outcomes.insert(id, failed);
} else {
outcomes.insert(id.clone(), InitOutcome::Ok { duration_ms });
handles.insert(id, handle);
}
}
Err(e) => {
let error = e.to_string();
tracing::warn!(
target: "plugins.init",
plugin_id = %id,
%error,
"subprocess plugin init failed; continuing"
);
outcomes.insert(id, InitOutcome::Failed { error });
}
}
}
Err(source) => {
let error = format!("auto-subprocess factory failed: {source}");
tracing::warn!(
target: "plugins.init",
plugin_id = %id,
%error,
"auto-subprocess plugin construction failed"
);
outcomes.insert(id, InitOutcome::Failed { error });
}
}
continue;
}
outcomes.insert(id, InitOutcome::NoHandle);
continue;
}
match factory_registry.instantiate(&id, &plugin.manifest) {
Err(FactoryInstantiateError::NotRegistered { .. }) => {
outcomes.insert(id, InitOutcome::NoHandle);
}
Err(FactoryInstantiateError::FactoryFailed { source, .. }) => {
let error = format!("factory failed: {source}");
tracing::warn!(
target: "plugins.init",
plugin_id = %id,
%error,
"plugin factory failed; recording Failed outcome"
);
outcomes.insert(id, InitOutcome::Failed { error });
}
Ok(handle) => {
let mut ctx = ctx_factory(&plugin.manifest, &plugin_cfg);
let start = std::time::Instant::now();
match handle.init(&mut ctx).await {
Ok(()) => {
let duration_ms = start.elapsed().as_millis() as u64;
if let Some(failed) = check_namespace_after_init(&id, &ctx) {
outcomes.insert(id, failed);
} else if let Some(failed) = register_remote_channels_after_init(
&id,
&handle,
channel_adapter_registry,
)
.await
{
outcomes.insert(id, failed);
} else if let Some(failed) =
register_remote_llm_providers_after_init(&id, &handle, llm_registry)
.await
{
outcomes.insert(id, failed);
} else if let Some(failed) =
register_remote_hook_handlers_after_init(&id, &handle, hook_registry)
.await
{
outcomes.insert(id, failed);
} else if let Some(failed) = register_remote_vector_backends_after_init(
&id,
&handle,
vector_backend_registry,
)
.await
{
outcomes.insert(id, failed);
} else if let Some(failed) = register_remote_tool_handlers_after_init(
&id,
&handle,
&ctx.tool_registry,
)
.await
{
outcomes.insert(id, failed);
} else if let Some(failed) = start_plugin_supervisor_loop_after_init(
&id, &handle, &ctx,
)
{
outcomes.insert(id, failed);
} else {
outcomes.insert(id.clone(), InitOutcome::Ok { duration_ms });
handles.insert(id, handle);
}
}
Err(e) => {
let error = e.to_string();
tracing::warn!(
target: "plugins.init",
plugin_id = %id,
%error,
"plugin init failed; continuing"
);
outcomes.insert(id, InitOutcome::Failed { error });
}
}
}
}
}
FactoryInitResult { outcomes, handles }
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use nexo_plugin_manifest::PluginManifest;
use super::super::report::PluginDiscoveryReport;
use super::super::DiscoveredPlugin;
fn discovered(plugin_id: &str) -> DiscoveredPlugin {
let raw = format!(
"[plugin]\n\
id = \"{plugin_id}\"\n\
version = \"0.1.0\"\n\
name = \"{plugin_id}\"\n\
description = \"fixture\"\n\
min_nexo_version = \">=0.0.1\"\n",
);
let manifest: PluginManifest = toml::from_str(&raw).unwrap();
DiscoveredPlugin {
manifest,
root_dir: PathBuf::from("/tmp/fake"),
manifest_path: PathBuf::from("/tmp/fake/nexo-plugin.toml"),
}
}
fn snapshot_with(plugins: Vec<DiscoveredPlugin>) -> NexoPluginRegistrySnapshot {
NexoPluginRegistrySnapshot {
plugins,
last_report: PluginDiscoveryReport::default(),
skill_roots: std::collections::BTreeMap::new(),
}
}
#[tokio::test]
async fn init_loop_records_no_handle_when_handles_empty() {
let snap = snapshot_with(vec![discovered("a"), discovered("b")]);
let outcomes = run_plugin_init_loop(
&snap,
&BTreeMap::new(),
|_m, _cfg| -> PluginInitContext<'_> {
unreachable!("ctx_factory should not be called when handles is empty");
},
)
.await;
assert_eq!(outcomes.len(), 2);
assert!(matches!(outcomes.get("a"), Some(InitOutcome::NoHandle)));
assert!(matches!(outcomes.get("b"), Some(InitOutcome::NoHandle)));
}
#[test]
fn init_outcome_serializes_to_json() {
let ok = InitOutcome::Ok { duration_ms: 12 };
let s = serde_json::to_string(&ok).unwrap();
assert!(s.contains("\"outcome\":\"ok\""));
assert!(s.contains("\"duration_ms\":12"));
let failed = InitOutcome::Failed {
error: "boom".into(),
};
let s = serde_json::to_string(&failed).unwrap();
assert!(s.contains("\"outcome\":\"failed\""));
assert!(s.contains("\"error\":\"boom\""));
let none = InitOutcome::NoHandle;
let s = serde_json::to_string(&none).unwrap();
assert!(s.contains("\"outcome\":\"no_handle\""));
}
#[tokio::test]
async fn run_plugin_init_loop_with_factory_routes_registered_vs_unregistered() {
use super::super::factory::{PluginFactory, PluginFactoryRegistry};
use crate::agent::plugin_host::PluginInitContext;
let snap = snapshot_with(vec![discovered("alpha"), discovered("beta")]);
let mut registry = PluginFactoryRegistry::new();
let factory: PluginFactory = Box::new(|_m| {
let err: super::super::factory::BoxError =
Box::new(std::io::Error::other("forced failure for test"));
Err(err)
});
registry.register("alpha", factory).unwrap();
let cfg_dir = tempfile::tempdir().unwrap();
let chan_reg = Arc::new(crate::agent::channel_adapter::ChannelAdapterRegistry::new());
let llm_reg = Arc::new(nexo_llm::LlmRegistry::new());
let hook_reg = Arc::new(crate::agent::hook_registry::HookRegistry::new());
let vec_reg = Arc::new(crate::agent::vector_backend_registry::VectorBackendRegistry::new());
let result = run_plugin_init_loop_with_factory(
&snap,
®istry,
cfg_dir.path(),
&chan_reg,
&llm_reg,
&hook_reg,
&vec_reg,
|_m, _cfg| -> PluginInitContext<'_> {
unreachable!("ctx_factory must NOT be invoked when the factory closure returns Err")
},
)
.await;
match result.outcomes.get("alpha") {
Some(InitOutcome::Failed { error }) => {
assert!(error.contains("forced failure"));
}
other => panic!("alpha must be Failed (factory closure errored), got {other:?}"),
}
assert!(matches!(
result.outcomes.get("beta"),
Some(InitOutcome::NoHandle)
));
assert!(result.handles.is_empty());
}
#[test]
fn format_violation_sample_truncates_after_three() {
use crate::agent::scoped_tool_registry::{NamespaceViolation, NamespaceViolationReason};
let violations = vec![
NamespaceViolation {
plugin_id: "p".into(),
attempted_name: "agent_x".into(),
reason: NamespaceViolationReason::ReservedPrefix("agent_"),
},
NamespaceViolation {
plugin_id: "p".into(),
attempted_name: "p_a".into(),
reason: NamespaceViolationReason::NotInExpose,
},
NamespaceViolation {
plugin_id: "p".into(),
attempted_name: "p_b".into(),
reason: NamespaceViolationReason::OutOfNamespace,
},
NamespaceViolation {
plugin_id: "p".into(),
attempted_name: "p_c".into(),
reason: NamespaceViolationReason::Collision,
},
];
let sample = format_violation_sample(&violations);
assert!(sample.contains("agent_x=ReservedPrefix"));
assert!(sample.contains("p_a=NotInExpose"));
assert!(sample.contains("p_b=OutOfNamespace"));
assert!(sample.contains("(+1 more)"));
assert!(!sample.contains("p_c"));
}
#[tokio::test]
async fn auto_subprocess_factory_produces_usable_handle() {
let mut manifest = discovered("auto_subproc").manifest;
manifest.plugin.entrypoint = nexo_plugin_manifest::EntrypointSection {
command: Some("/bin/true".to_string()),
..Default::default()
};
let factory = subprocess_plugin_factory(manifest.clone());
match factory(&manifest) {
Ok(plugin) => assert_eq!(plugin.manifest().plugin.id, "auto_subproc"),
Err(e) => panic!("auto-subprocess factory must build handle, got {e}"),
}
}
#[tokio::test]
async fn auto_subprocess_fallback_skips_manifests_without_entrypoint() {
use super::super::factory::PluginFactoryRegistry;
let snap = snapshot_with(vec![discovered("in_tree_only")]);
let registry = PluginFactoryRegistry::new();
let cfg_dir = tempfile::tempdir().unwrap();
let chan_reg = Arc::new(crate::agent::channel_adapter::ChannelAdapterRegistry::new());
let llm_reg = Arc::new(nexo_llm::LlmRegistry::new());
let hook_reg = Arc::new(crate::agent::hook_registry::HookRegistry::new());
let vec_reg = Arc::new(crate::agent::vector_backend_registry::VectorBackendRegistry::new());
let result = run_plugin_init_loop_with_factory(
&snap,
®istry,
cfg_dir.path(),
&chan_reg,
&llm_reg,
&hook_reg,
&vec_reg,
|_m, _cfg| -> PluginInitContext<'_> {
unreachable!("ctx_factory must NOT be invoked for non-subprocess manifests")
},
)
.await;
assert!(
matches!(
result.outcomes.get("in_tree_only"),
Some(InitOutcome::NoHandle)
),
"in-tree manifest without entrypoint must record NoHandle"
);
assert!(result.handles.is_empty(), "no handles for NoHandle outcome");
}
#[tokio::test]
async fn init_loop_records_failed_when_config_load_fails() {
use super::super::factory::PluginFactory;
let raw = "[plugin]\n\
id = \"slack\"\n\
version = \"0.1.0\"\n\
name = \"slack\"\n\
description = \"fixture\"\n\
min_nexo_version = \">=0.0.1\"\n\
\n\
[plugin.config]\n\
schema_path = \"missing-schema.json\"\n";
let manifest: PluginManifest = toml::from_str(raw).unwrap();
let plugin_root = tempfile::tempdir().unwrap();
let snap = snapshot_with(vec![DiscoveredPlugin {
manifest,
root_dir: plugin_root.path().to_path_buf(),
manifest_path: plugin_root.path().join("nexo-plugin.toml"),
}]);
let cfg_dir = tempfile::tempdir().unwrap();
let plugin_cfg_dir = cfg_dir.path().join("plugins").join("slack");
std::fs::create_dir_all(&plugin_cfg_dir).unwrap();
std::fs::write(plugin_cfg_dir.join("00.yaml"), "x: 1\n").unwrap();
let mut registry = PluginFactoryRegistry::new();
let factory: PluginFactory = Box::new(|_m| {
let err: super::super::factory::BoxError =
Box::new(std::io::Error::other("factory should not be reached"));
Err(err)
});
registry.register("slack", factory).unwrap();
let chan_reg = Arc::new(crate::agent::channel_adapter::ChannelAdapterRegistry::new());
let llm_reg = Arc::new(nexo_llm::LlmRegistry::new());
let hook_reg = Arc::new(crate::agent::hook_registry::HookRegistry::new());
let vec_reg = Arc::new(crate::agent::vector_backend_registry::VectorBackendRegistry::new());
let result = run_plugin_init_loop_with_factory(
&snap,
®istry,
cfg_dir.path(),
&chan_reg,
&llm_reg,
&hook_reg,
&vec_reg,
|_m, _cfg| -> PluginInitContext<'_> {
unreachable!("ctx_factory must NOT be invoked when config load fails")
},
)
.await;
match result.outcomes.get("slack") {
Some(InitOutcome::Failed { error }) => {
assert!(
error.starts_with("config load:"),
"expected config-load failure, got {error}"
);
assert!(error.contains("missing-schema.json"));
}
other => panic!("expected Failed, got {other:?}"),
}
assert!(result.handles.is_empty());
}
}