1use super::context::AgentContext;
5use super::tool_registry::ToolHandler;
6use async_trait::async_trait;
7use nexo_extensions::{StdioRuntime, ToolDescriptor};
8use nexo_llm::ToolDef;
9use serde_json::Value;
10use std::sync::Arc;
11pub const EXT_NAME_PREFIX: &str = "ext_";
14pub struct ExtensionTool {
15 plugin_id: String,
16 tool_name: String,
17 runtime: Arc<StdioRuntime>,
18 context_passthrough: bool,
19 description: Option<String>,
23 input_schema: Option<Value>,
24}
25impl ExtensionTool {
26 pub fn new(
27 plugin_id: impl Into<String>,
28 tool_name: impl Into<String>,
29 runtime: Arc<StdioRuntime>,
30 ) -> Self {
31 Self {
32 plugin_id: plugin_id.into(),
33 tool_name: tool_name.into(),
34 runtime,
35 context_passthrough: false,
36 description: None,
37 input_schema: None,
38 }
39 }
40 pub fn with_descriptor_metadata(
44 mut self,
45 description: impl Into<String>,
46 input_schema: Value,
47 ) -> Self {
48 self.description = Some(description.into());
49 self.input_schema = Some(input_schema);
50 self
51 }
52 pub fn with_context_passthrough(mut self, enabled: bool) -> Self {
56 self.context_passthrough = enabled;
57 self
58 }
59 pub fn plugin_id(&self) -> &str {
60 &self.plugin_id
61 }
62 pub fn tool_name(&self) -> &str {
63 &self.tool_name
64 }
65 pub fn description(&self) -> Option<&str> {
69 self.description.as_deref()
70 }
71 pub fn input_schema(&self) -> Option<&Value> {
75 self.input_schema.as_ref()
76 }
77 pub fn prefixed_name(plugin_id: &str, tool_name: &str) -> String {
82 ToolDef::fit_name(EXT_NAME_PREFIX, plugin_id, tool_name)
83 }
84 pub fn tool_def(desc: &ToolDescriptor, plugin_id: &str) -> ToolDef {
88 ToolDef {
89 name: Self::prefixed_name(plugin_id, &desc.name),
90 description: format!("[ext:{plugin_id}] {}", desc.description),
91 parameters: desc.input_schema.clone(),
92 }
93 }
94}
95pub(crate) fn inject_context_meta(
134 passthrough: bool,
135 ctx: &AgentContext,
136 args: Value,
137 plugin_id: &str,
138 tool_name: &str,
139) -> Value {
140 if !passthrough {
141 return args;
142 }
143 let mut args = args;
144 match args.as_object_mut() {
145 Some(obj) => {
146 obj.insert("_meta".into(), ctx.build_meta_value());
150 }
151 None => tracing::debug!(
152 ext = %plugin_id,
153 tool = %tool_name,
154 "context_passthrough skipped: args is not an object"
155 ),
156 }
157 args
158}
159#[async_trait]
160impl ToolHandler for ExtensionTool {
161 async fn call(&self, ctx: &AgentContext, args: Value) -> anyhow::Result<Value> {
162 let args = inject_context_meta(
163 self.context_passthrough,
164 ctx,
165 args,
166 &self.plugin_id,
167 &self.tool_name,
168 );
169 self.runtime
170 .tools_call(&self.tool_name, args)
171 .await
172 .map_err(|e| {
173 anyhow::anyhow!(
174 "extension `{}` tool `{}` failed: {e}",
175 self.plugin_id,
176 self.tool_name
177 )
178 })
179 }
180}
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use crate::session::SessionManager;
185 use nexo_broker::AnyBroker;
186 use nexo_config::types::agents::{
187 AgentConfig, AgentRuntimeConfig, HeartbeatConfig, ModelConfig,
188 };
189 use std::time::Duration;
190 use uuid::Uuid;
191 fn test_ctx(agent: &str, session: Option<Uuid>) -> AgentContext {
192 let cfg = Arc::new(AgentConfig {
193 id: agent.into(),
194 model: ModelConfig {
195 provider: "stub".into(),
196 model: "m1".into(),
197 },
198 plugins: vec![],
199 heartbeat: HeartbeatConfig::default(),
200 config: AgentRuntimeConfig::default(),
201 system_prompt: String::new(),
202 workspace: String::new(),
203 skills: vec![],
204 skills_dir: "./skills".into(),
205 skill_overrides: Default::default(),
206 transcripts_dir: String::new(),
207 dreaming: Default::default(),
208 workspace_git: Default::default(),
209 tool_rate_limits: None,
210 tool_args_validation: None,
211 extra_docs: Vec::new(),
212 inbound_bindings: Vec::new(),
213 allowed_tools: Vec::new(),
214 sender_rate_limit: None,
215 allowed_delegates: Vec::new(),
216 accept_delegates_from: Vec::new(),
217 description: String::new(),
218 outbound_allowlist: Default::default(),
219 google_auth: None,
220 credentials: Default::default(),
221 link_understanding: serde_json::Value::Null,
222 web_search: serde_json::Value::Null,
223 pairing_policy: serde_json::Value::Null,
224 language: None,
225 context_optimization: None,
226 dispatch_policy: Default::default(),
227 plan_mode: Default::default(),
228 remote_triggers: Vec::new(),
229 lsp: nexo_config::types::lsp::LspPolicy::default(),
230 config_tool: nexo_config::types::config_tool::ConfigToolPolicy::default(),
231 team: nexo_config::types::team::TeamPolicy::default(),
232 proactive: Default::default(),
233 repl: Default::default(),
234 auto_dream: None,
235 assistant_mode: None,
236 away_summary: None,
237 brief: None,
238 channels: None,
239 auto_approve: false,
240 extract_memories: None,
241 event_subscribers: Vec::new(),
242 tenant_id: None,
243 extensions_config: std::collections::BTreeMap::new(),
244 active: true,
245 });
246 let broker = AnyBroker::local();
247 let sessions = Arc::new(SessionManager::new(Duration::from_secs(60), 20));
248 let ctx = AgentContext::new(agent, cfg, broker, sessions);
249 match session {
250 Some(id) => ctx.with_session_id(id),
251 None => ctx,
252 }
253 }
254 #[tokio::test]
255 async fn inject_meta_passthrough_off_returns_args_unchanged() {
256 let ctx = test_ctx("kate", Some(Uuid::new_v4()));
257 let args = serde_json::json!({"x": 1});
258 let out = inject_context_meta(false, &ctx, args.clone(), "weather", "get");
259 assert_eq!(out, args);
260 }
261 #[tokio::test]
262 async fn inject_meta_on_object_adds_meta_field() {
263 let sid = Uuid::new_v4();
264 let ctx = test_ctx("kate", Some(sid));
265 let args = serde_json::json!({"x": 1});
266 let out = inject_context_meta(true, &ctx, args, "weather", "get");
267 assert_eq!(out["x"], 1);
268 assert_eq!(out["_meta"]["agent_id"], "kate");
269 assert_eq!(out["_meta"]["session_id"], sid.to_string());
270 }
271 #[tokio::test]
272 async fn inject_meta_session_none_serializes_null() {
273 let ctx = test_ctx("kate", None);
274 let out = inject_context_meta(true, &ctx, serde_json::json!({}), "weather", "get");
275 assert_eq!(out["_meta"]["agent_id"], "kate");
276 assert!(out["_meta"]["session_id"].is_null());
277 }
278 #[tokio::test]
279 async fn inject_meta_scalar_args_passthrough_unchanged() {
280 let ctx = test_ctx("kate", Some(Uuid::new_v4()));
281 let out = inject_context_meta(
282 true,
283 &ctx,
284 serde_json::json!("raw string"),
285 "weather",
286 "get",
287 );
288 assert_eq!(out, serde_json::json!("raw string"));
289 }
290 #[tokio::test]
291 async fn inject_meta_overrides_existing_meta() {
292 let ctx = test_ctx("kate", Some(Uuid::new_v4()));
293 let args = serde_json::json!({"_meta": {"spoofed": true}});
294 let out = inject_context_meta(true, &ctx, args, "weather", "get");
295 assert_eq!(out["_meta"]["agent_id"], "kate");
296 assert!(out["_meta"].get("spoofed").is_none());
297 }
298 #[test]
299 fn prefixed_name_concatenates_prefix_id_and_tool() {
300 assert_eq!(
301 ExtensionTool::prefixed_name("weather", "get_forecast"),
302 "ext_weather_get_forecast"
303 );
304 }
305 #[test]
306 fn tool_def_decorates_description() {
307 let desc = ToolDescriptor {
308 name: "echo".into(),
309 description: "Echoes its input".into(),
310 input_schema: serde_json::json!({"type":"object"}),
311 };
312 let def = ExtensionTool::tool_def(&desc, "util");
313 assert_eq!(def.name, "ext_util_echo");
314 assert_eq!(def.description, "[ext:util] Echoes its input");
315 assert_eq!(def.parameters, serde_json::json!({"type":"object"}));
316 }
317 #[test]
318 fn prefixed_name_passthrough_when_short() {
319 assert_eq!(
320 ExtensionTool::prefixed_name("weather", "get_forecast"),
321 "ext_weather_get_forecast"
322 );
323 }
324 #[test]
325 fn prefixed_name_exactly_at_max_is_unchanged() {
326 let id = "a".repeat(29);
327 let tool = "b".repeat(30); let name = ExtensionTool::prefixed_name(&id, &tool);
329 assert_eq!(name.len(), 64);
330 assert!(name.starts_with("ext_"));
331 assert!(!name.contains("__"));
332 }
333 #[test]
334 fn long_name_is_hashed_into_limit() {
335 let id = "mybot";
336 let tool = "very_long_tool_name_".repeat(10); let name = ExtensionTool::prefixed_name(id, &tool);
338 assert_eq!(name.len(), 64);
339 assert!(name.starts_with("ext_mybot_"));
340 }
341 #[test]
342 fn different_long_tools_yield_different_names() {
343 let id = "mybot";
344 let a = ExtensionTool::prefixed_name(id, &("aaa".repeat(50)));
345 let b = ExtensionTool::prefixed_name(id, &("bbb".repeat(50)));
346 assert_ne!(a, b);
347 assert_eq!(a.len(), 64);
348 assert_eq!(b.len(), 64);
349 }
350
351 use crate::agent::context::BindingContext;
356
357 fn full_binding(
358 agent: &str,
359 session: Uuid,
360 channel: &str,
361 account: &str,
362 mcp: Option<&str>,
363 ) -> BindingContext {
364 let mut b = BindingContext::agent_only(agent);
365 b.session_id = Some(session);
366 b.channel = Some(channel.into());
367 b.account_id = Some(account.into());
368 b.binding_id = Some(format!("{channel}:{account}"));
369 if let Some(s) = mcp {
370 b = b.with_mcp_channel_source(s);
371 }
372 b
373 }
374
375 #[tokio::test]
376 async fn step5_inject_meta_with_binding_writes_nested_namespace() {
377 let mut ctx = test_ctx("ana", Some(Uuid::nil()));
378 ctx.binding = Some(full_binding(
379 "ana",
380 Uuid::nil(),
381 "whatsapp",
382 "personal",
383 None,
384 ));
385 let args = serde_json::json!({"to": "+5491100", "body": "hi"});
386 let out = inject_context_meta(true, &ctx, args, "ventas-etb", "etb_register_lead");
387
388 assert_eq!(out["to"], "+5491100");
390 assert_eq!(out["body"], "hi");
391
392 assert_eq!(out["_meta"]["agent_id"], "ana");
394 assert!(out["_meta"]["session_id"].is_string());
395
396 let binding = &out["_meta"]["nexo"]["binding"];
398 assert_eq!(binding["agent_id"], "ana");
399 assert_eq!(binding["channel"], "whatsapp");
400 assert_eq!(binding["account_id"], "personal");
401 assert_eq!(binding["binding_id"], "whatsapp:personal");
402 assert!(binding.get("mcp_channel_source").is_none());
405 }
406
407 #[tokio::test]
408 async fn step5_inject_meta_with_mcp_channel_source_emits_field() {
409 let mut ctx = test_ctx("ana", Some(Uuid::nil()));
410 ctx.binding = Some(full_binding(
411 "ana",
412 Uuid::nil(),
413 "telegram",
414 "kate_tg",
415 Some("slack"),
416 ));
417 let out = inject_context_meta(true, &ctx, serde_json::json!({}), "marketing", "send_drip");
418 let binding = &out["_meta"]["nexo"]["binding"];
419 assert_eq!(binding["mcp_channel_source"], "slack");
420 }
421
422 #[tokio::test]
423 async fn step5_inject_meta_without_binding_omits_nexo_block() {
424 let ctx = test_ctx("delegation", None);
428 let out = inject_context_meta(true, &ctx, serde_json::json!({}), "anything", "method");
429 assert_eq!(out["_meta"]["agent_id"], "delegation");
430 assert!(out["_meta"]["session_id"].is_null());
431 assert!(out["_meta"].get("nexo").is_none());
432 }
433
434 #[tokio::test]
435 async fn step5_inject_meta_passthrough_off_still_works() {
436 let mut ctx = test_ctx("ana", Some(Uuid::nil()));
437 ctx.binding = Some(BindingContext::agent_only("ana"));
438 let args = serde_json::json!({"x": 1});
439 let out = inject_context_meta(false, &ctx, args.clone(), "p", "t");
441 assert_eq!(out, args);
442 assert!(out.get("_meta").is_none());
443 }
444
445 #[tokio::test]
446 async fn step5_inject_meta_legacy_consumer_keeps_working() {
447 let mut ctx = test_ctx("carlos", Some(Uuid::nil()));
451 ctx.binding = Some(BindingContext::agent_only("carlos"));
452 let out = inject_context_meta(true, &ctx, serde_json::json!({}), "p", "t");
453 assert_eq!(out["_meta"]["agent_id"], "carlos");
456 }
457}