use super::context::AgentContext;
use super::tool_registry::ToolHandler;
use async_trait::async_trait;
use nexo_extensions::{StdioRuntime, ToolDescriptor};
use nexo_llm::ToolDef;
use serde_json::Value;
use std::sync::Arc;
pub const EXT_NAME_PREFIX: &str = "ext_";
pub struct ExtensionTool {
plugin_id: String,
tool_name: String,
runtime: Arc<StdioRuntime>,
context_passthrough: bool,
description: Option<String>,
input_schema: Option<Value>,
}
impl ExtensionTool {
pub fn new(
plugin_id: impl Into<String>,
tool_name: impl Into<String>,
runtime: Arc<StdioRuntime>,
) -> Self {
Self {
plugin_id: plugin_id.into(),
tool_name: tool_name.into(),
runtime,
context_passthrough: false,
description: None,
input_schema: None,
}
}
pub fn with_descriptor_metadata(
mut self,
description: impl Into<String>,
input_schema: Value,
) -> Self {
self.description = Some(description.into());
self.input_schema = Some(input_schema);
self
}
pub fn with_context_passthrough(mut self, enabled: bool) -> Self {
self.context_passthrough = enabled;
self
}
pub fn plugin_id(&self) -> &str {
&self.plugin_id
}
pub fn tool_name(&self) -> &str {
&self.tool_name
}
pub fn description(&self) -> Option<&str> {
self.description.as_deref()
}
pub fn input_schema(&self) -> Option<&Value> {
self.input_schema.as_ref()
}
pub fn prefixed_name(plugin_id: &str, tool_name: &str) -> String {
ToolDef::fit_name(EXT_NAME_PREFIX, plugin_id, tool_name)
}
pub fn tool_def(desc: &ToolDescriptor, plugin_id: &str) -> ToolDef {
ToolDef {
name: Self::prefixed_name(plugin_id, &desc.name),
description: format!("[ext:{plugin_id}] {}", desc.description),
parameters: desc.input_schema.clone(),
}
}
}
pub(crate) fn inject_context_meta(
passthrough: bool,
ctx: &AgentContext,
args: Value,
plugin_id: &str,
tool_name: &str,
) -> Value {
if !passthrough {
return args;
}
let mut args = args;
match args.as_object_mut() {
Some(obj) => {
obj.insert("_meta".into(), ctx.build_meta_value());
}
None => tracing::debug!(
ext = %plugin_id,
tool = %tool_name,
"context_passthrough skipped: args is not an object"
),
}
args
}
#[async_trait]
impl ToolHandler for ExtensionTool {
async fn call(&self, ctx: &AgentContext, args: Value) -> anyhow::Result<Value> {
let args = inject_context_meta(
self.context_passthrough,
ctx,
args,
&self.plugin_id,
&self.tool_name,
);
self.runtime
.tools_call(&self.tool_name, args)
.await
.map_err(|e| {
anyhow::anyhow!(
"extension `{}` tool `{}` failed: {e}",
self.plugin_id,
self.tool_name
)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::SessionManager;
use nexo_broker::AnyBroker;
use nexo_config::types::agents::{
AgentConfig, AgentRuntimeConfig, HeartbeatConfig, ModelConfig,
};
use std::time::Duration;
use uuid::Uuid;
fn test_ctx(agent: &str, session: Option<Uuid>) -> AgentContext {
let cfg = Arc::new(AgentConfig {
id: agent.into(),
model: ModelConfig {
provider: "stub".into(),
model: "m1".into(),
},
plugins: vec![],
heartbeat: HeartbeatConfig::default(),
config: AgentRuntimeConfig::default(),
system_prompt: String::new(),
workspace: String::new(),
skills: vec![],
skills_dir: "./skills".into(),
skill_overrides: Default::default(),
transcripts_dir: String::new(),
dreaming: Default::default(),
workspace_git: Default::default(),
tool_rate_limits: None,
tool_args_validation: None,
extra_docs: Vec::new(),
inbound_bindings: Vec::new(),
allowed_tools: Vec::new(),
sender_rate_limit: None,
allowed_delegates: Vec::new(),
accept_delegates_from: Vec::new(),
description: String::new(),
outbound_allowlist: Default::default(),
google_auth: None,
credentials: Default::default(),
link_understanding: serde_json::Value::Null,
web_search: serde_json::Value::Null,
pairing_policy: serde_json::Value::Null,
language: None,
locale_prompts: Default::default(),
context_optimization: None,
dispatch_policy: Default::default(),
plan_mode: Default::default(),
remote_triggers: Vec::new(),
lsp: nexo_config::types::lsp::LspPolicy::default(),
config_tool: nexo_config::types::config_tool::ConfigToolPolicy::default(),
team: nexo_config::types::team::TeamPolicy::default(),
proactive: Default::default(),
repl: Default::default(),
auto_dream: None,
assistant_mode: None,
away_summary: None,
brief: None,
channels: None,
auto_approve: false,
extract_memories: None,
event_subscribers: Vec::new(),
tenant_id: None,
extensions_config: std::collections::BTreeMap::new(),
active: true,
});
let broker = AnyBroker::local();
let sessions = Arc::new(SessionManager::new(Duration::from_secs(60), 20));
let ctx = AgentContext::new(agent, cfg, broker, sessions);
match session {
Some(id) => ctx.with_session_id(id),
None => ctx,
}
}
#[tokio::test]
async fn inject_meta_passthrough_off_returns_args_unchanged() {
let ctx = test_ctx("kate", Some(Uuid::new_v4()));
let args = serde_json::json!({"x": 1});
let out = inject_context_meta(false, &ctx, args.clone(), "weather", "get");
assert_eq!(out, args);
}
#[tokio::test]
async fn inject_meta_on_object_adds_meta_field() {
let sid = Uuid::new_v4();
let ctx = test_ctx("kate", Some(sid));
let args = serde_json::json!({"x": 1});
let out = inject_context_meta(true, &ctx, args, "weather", "get");
assert_eq!(out["x"], 1);
assert_eq!(out["_meta"]["agent_id"], "kate");
assert_eq!(out["_meta"]["session_id"], sid.to_string());
}
#[tokio::test]
async fn inject_meta_session_none_serializes_null() {
let ctx = test_ctx("kate", None);
let out = inject_context_meta(true, &ctx, serde_json::json!({}), "weather", "get");
assert_eq!(out["_meta"]["agent_id"], "kate");
assert!(out["_meta"]["session_id"].is_null());
}
#[tokio::test]
async fn inject_meta_scalar_args_passthrough_unchanged() {
let ctx = test_ctx("kate", Some(Uuid::new_v4()));
let out = inject_context_meta(
true,
&ctx,
serde_json::json!("raw string"),
"weather",
"get",
);
assert_eq!(out, serde_json::json!("raw string"));
}
#[tokio::test]
async fn inject_meta_overrides_existing_meta() {
let ctx = test_ctx("kate", Some(Uuid::new_v4()));
let args = serde_json::json!({"_meta": {"spoofed": true}});
let out = inject_context_meta(true, &ctx, args, "weather", "get");
assert_eq!(out["_meta"]["agent_id"], "kate");
assert!(out["_meta"].get("spoofed").is_none());
}
#[test]
fn prefixed_name_concatenates_prefix_id_and_tool() {
assert_eq!(
ExtensionTool::prefixed_name("weather", "get_forecast"),
"ext_weather_get_forecast"
);
}
#[test]
fn tool_def_decorates_description() {
let desc = ToolDescriptor {
name: "echo".into(),
description: "Echoes its input".into(),
input_schema: serde_json::json!({"type":"object"}),
};
let def = ExtensionTool::tool_def(&desc, "util");
assert_eq!(def.name, "ext_util_echo");
assert_eq!(def.description, "[ext:util] Echoes its input");
assert_eq!(def.parameters, serde_json::json!({"type":"object"}));
}
#[test]
fn prefixed_name_passthrough_when_short() {
assert_eq!(
ExtensionTool::prefixed_name("weather", "get_forecast"),
"ext_weather_get_forecast"
);
}
#[test]
fn prefixed_name_exactly_at_max_is_unchanged() {
let id = "a".repeat(29);
let tool = "b".repeat(30); let name = ExtensionTool::prefixed_name(&id, &tool);
assert_eq!(name.len(), 64);
assert!(name.starts_with("ext_"));
assert!(!name.contains("__"));
}
#[test]
fn long_name_is_hashed_into_limit() {
let id = "mybot";
let tool = "very_long_tool_name_".repeat(10); let name = ExtensionTool::prefixed_name(id, &tool);
assert_eq!(name.len(), 64);
assert!(name.starts_with("ext_mybot_"));
}
#[test]
fn different_long_tools_yield_different_names() {
let id = "mybot";
let a = ExtensionTool::prefixed_name(id, &("aaa".repeat(50)));
let b = ExtensionTool::prefixed_name(id, &("bbb".repeat(50)));
assert_ne!(a, b);
assert_eq!(a.len(), 64);
assert_eq!(b.len(), 64);
}
use crate::agent::context::BindingContext;
fn full_binding(
agent: &str,
session: Uuid,
channel: &str,
account: &str,
mcp: Option<&str>,
) -> BindingContext {
let mut b = BindingContext::agent_only(agent);
b.session_id = Some(session);
b.channel = Some(channel.into());
b.account_id = Some(account.into());
b.binding_id = Some(format!("{channel}:{account}"));
if let Some(s) = mcp {
b = b.with_mcp_channel_source(s);
}
b
}
#[tokio::test]
async fn step5_inject_meta_with_binding_writes_nested_namespace() {
let mut ctx = test_ctx("ana", Some(Uuid::nil()));
ctx.binding = Some(full_binding(
"ana",
Uuid::nil(),
"whatsapp",
"personal",
None,
));
let args = serde_json::json!({"to": "+5491100", "body": "hi"});
let out = inject_context_meta(true, &ctx, args, "ventas-etb", "etb_register_lead");
assert_eq!(out["to"], "+5491100");
assert_eq!(out["body"], "hi");
assert_eq!(out["_meta"]["agent_id"], "ana");
assert!(out["_meta"]["session_id"].is_string());
let binding = &out["_meta"]["nexo"]["binding"];
assert_eq!(binding["agent_id"], "ana");
assert_eq!(binding["channel"], "whatsapp");
assert_eq!(binding["account_id"], "personal");
assert_eq!(binding["binding_id"], "whatsapp:personal");
assert!(binding.get("mcp_channel_source").is_none());
}
#[tokio::test]
async fn step5_inject_meta_with_mcp_channel_source_emits_field() {
let mut ctx = test_ctx("ana", Some(Uuid::nil()));
ctx.binding = Some(full_binding(
"ana",
Uuid::nil(),
"telegram",
"kate_tg",
Some("slack"),
));
let out = inject_context_meta(true, &ctx, serde_json::json!({}), "marketing", "send_drip");
let binding = &out["_meta"]["nexo"]["binding"];
assert_eq!(binding["mcp_channel_source"], "slack");
}
#[tokio::test]
async fn step5_inject_meta_without_binding_omits_nexo_block() {
let ctx = test_ctx("delegation", None);
let out = inject_context_meta(true, &ctx, serde_json::json!({}), "anything", "method");
assert_eq!(out["_meta"]["agent_id"], "delegation");
assert!(out["_meta"]["session_id"].is_null());
assert!(out["_meta"].get("nexo").is_none());
}
#[tokio::test]
async fn step5_inject_meta_passthrough_off_still_works() {
let mut ctx = test_ctx("ana", Some(Uuid::nil()));
ctx.binding = Some(BindingContext::agent_only("ana"));
let args = serde_json::json!({"x": 1});
let out = inject_context_meta(false, &ctx, args.clone(), "p", "t");
assert_eq!(out, args);
assert!(out.get("_meta").is_none());
}
#[tokio::test]
async fn step5_inject_meta_legacy_consumer_keeps_working() {
let mut ctx = test_ctx("carlos", Some(Uuid::nil()));
ctx.binding = Some(BindingContext::agent_only("carlos"));
let out = inject_context_meta(true, &ctx, serde_json::json!({}), "p", "t");
assert_eq!(out["_meta"]["agent_id"], "carlos");
}
}