Skip to main content

nexo_core/agent/
extension_tool.rs

1//! Phase 11.5 — bridge an extension's JSON-RPC tools into the agent's
2//! `ToolRegistry`. The LLM sees the tool (prefixed + attributed in the
3//! description); calls are routed to the owning `StdioRuntime`.
4use 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;
11/// Prefix prepended to every extension-provided tool name so they cannot
12/// collide with native tools like `memory`, `heartbeat`, `who_am_i`.
13pub 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    /// Populated when constructed via `with_descriptor` so hooks can
20    /// inspect what the extension advertised. Falls back to empty when
21    /// the caller only had name + runtime handy.
22    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    /// Attach `description` + `input_schema` after construction so
41    /// lifecycle hooks / introspection code can read what the extension
42    /// advertised at handshake time. Idempotent.
43    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    /// Phase 11.5 follow-up — builder to opt this tool into context
53    /// propagation. When true, `call` injects `_meta.agent_id` and
54    /// `_meta.session_id` into the args object before forwarding.
55    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    /// The description the extension advertised at registration time.
66    /// `None` when the tool was constructed without a descriptor (e.g.
67    /// legacy call sites).
68    pub fn description(&self) -> Option<&str> {
69        self.description.as_deref()
70    }
71    /// The `input_schema` (JSON Schema) the extension advertised at
72    /// registration time. `None` when the tool was constructed without
73    /// a descriptor.
74    pub fn input_schema(&self) -> Option<&Value> {
75        self.input_schema.as_ref()
76    }
77    /// Full LLM-facing tool name: `ext_{plugin_id}_{tool_name}`. When the
78    /// concatenation overflows `ToolDef::MAX_NAME_LEN`, falls back to a
79    /// deterministic `ext_{id}_{head}_{hash6}` form — see
80    /// `ToolDef::fit_name`. Routing still uses the original `tool_name`.
81    pub fn prefixed_name(plugin_id: &str, tool_name: &str) -> String {
82        ToolDef::fit_name(EXT_NAME_PREFIX, plugin_id, tool_name)
83    }
84    /// Build a `ToolDef` ready for `ToolRegistry::register`. Decorates the
85    /// description with `[ext:<id>]` so the LLM knows where the tool comes
86    /// from when reasoning over its options.
87    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}
95/// Inject `_meta` into `args` when `passthrough` is true and
96/// `args` is a JSON object. No-op otherwise (non-object payloads
97/// pass through unchanged).
98///
99/// Phase 82.1 Step 5 — wire shape:
100/// ```jsonc
101/// {
102///   "...": "tool args as the LLM produced them",
103///   "_meta": {
104///     // Legacy fields kept for one release of grace so
105///     // extensions that read `_meta.agent_id` / `_meta.session_id`
106///     // directly keep working unchanged.
107///     "agent_id": "ana",
108///     "session_id": "550e8400-...",
109///     // New nested namespace. `BindingContext` serialises here
110///     // verbatim — extensions read e.g. `_meta.nexo.binding.channel`
111///     // to discover which channel + account_id triggered the call.
112///     "nexo": {
113///       "binding": {
114///         "agent_id": "ana",
115///         "session_id": "550e8400-...",
116///         "channel": "whatsapp",
117///         "account_id": "personal",
118///         "binding_id": "whatsapp:personal",
119///         "mcp_channel_source": "slack" // optional, Phase 80.9
120///       }
121///     }
122///   }
123/// }
124/// ```
125///
126/// Bindingless paths (delegation receive, heartbeat bootstrap,
127/// tests) emit only `agent_id` + `session_id` — the nested
128/// `binding` block is omitted because there's no
129/// `(channel, account_id, binding_id)` tuple to populate.
130///
131/// Extracted as a pure function so it is unit-testable without a
132/// live runtime.
133pub(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            // Single source of truth for `_meta` lives on
147            // AgentContext — same shape feeds Phase 11 stdio
148            // and Phase 12 MCP tools/call (Step 6).
149            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); // 4 + 29 + 1 + 30 = 64
328        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); // ~200 chars
337        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    // ----------------------------------------------------------
352    // Phase 82.1 Step 5 — dual-write `_meta.nexo.binding`
353    // ----------------------------------------------------------
354
355    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        // Original args preserved.
389        assert_eq!(out["to"], "+5491100");
390        assert_eq!(out["body"], "hi");
391
392        // Legacy flat block intact (dual-write — backward compat).
393        assert_eq!(out["_meta"]["agent_id"], "ana");
394        assert!(out["_meta"]["session_id"].is_string());
395
396        // New nested binding block.
397        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        // mcp_channel_source absent because field is None and serde
403        // skips serializing.
404        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        // Bindingless path (delegation receive, heartbeat
425        // bootstrap, tests). Legacy flat block still emitted;
426        // nested `nexo` block omitted to keep the wire compact.
427        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        // Even with binding populated, passthrough off → no-op.
440        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        // Defense for backward-compat: a legacy extension that
448        // ONLY reads `_meta.agent_id` (does not know about
449        // `_meta.nexo.binding`) keeps working unchanged.
450        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        // Legacy reader reads top-level `_meta.agent_id` — must still
454        // work even though we layered `_meta.nexo.binding` underneath.
455        assert_eq!(out["_meta"]["agent_id"], "carlos");
456    }
457}