Skip to main content

nexo_core/agent/
generic_rpc_tool.rs

1//! Phase 81.33.a — generic RPC tool handler.
2//!
3//! `GenericRpcToolHandler` implements [`ToolHandler`] by translating
4//! each LLM tool call into a JSON-RPC request against a subprocess
5//! plugin (Phase 81.14.b stdio bridge). It is the daemon-side
6//! counterpart to the plugin-side `outbound_tool.invoke` handler
7//! declared in `nexo-plugin.toml::[[plugin.tools.outbound]]`.
8//!
9//! ## Why
10//!
11//! Before Phase 81.33, the daemon hardcoded
12//! `nexo_plugin_whatsapp::register_whatsapp_tools(&tools)` /
13//! `nexo_plugin_telegram::register_telegram_tools(&tools)` /
14//! etc. at boot. That meant every new channel (slack, discord,
15//! sms, instagram, …) required editing `src/main.rs` AND adding
16//! a daemon Cargo.toml dep on the plugin crate. This couples the
17//! daemon to a fixed plugin set and prevents community-tier
18//! out-of-tree plugins from shipping outbound tools.
19//!
20//! With this handler:
21//!
22//!   1. Plugin declares `[[plugin.tools.outbound]]` entries in its
23//!      own manifest (name + description + JSON schema + RPC
24//!      method).
25//!   2. `SubprocessNexoPlugin::register_outbound_tools` iterates
26//!      the manifest and installs one `GenericRpcToolHandler` per
27//!      entry against the per-agent `ToolRegistry`.
28//!   3. LLM calls the tool → handler serialises
29//!      `{"tool_name":"...", "args":{...}}` into a JSON-RPC
30//!      request → subprocess dispatches → result flows back.
31//!
32//! The daemon depends on **zero plugin-specific crates** for the
33//! outbound-tool hot path.
34//!
35//! ## Respawn semantics
36//!
37//! The handler holds `Weak<SubprocessNexoPlugin>` (NOT `Arc`) so
38//! a respawned plugin instance gets a fresh registration without
39//! the old handler keeping the dead `Inner` alive. After
40//! supervisor respawn, the per-agent `ToolRegistry` is rebuilt
41//! at the next hot-spawn / reload, installing handlers against
42//! the new `Inner`'s `pending` + `stdin_tx`.
43
44use std::sync::Weak;
45use std::time::Duration;
46
47use async_trait::async_trait;
48use serde_json::Value;
49
50use crate::agent::nexo_plugin_registry::subprocess::SubprocessNexoPlugin;
51use crate::agent::tool_registry::ToolHandler;
52use crate::agent::AgentContext;
53
54/// Default outbound-tool call timeout when the manifest doesn't
55/// override + `NEXO_PLUGIN_TOOL_TIMEOUT_MS` env is unset.
56pub const DEFAULT_OUTBOUND_TOOL_TIMEOUT: Duration = Duration::from_secs(60);
57
58/// JSON-RPC error codes mapped to typed [`anyhow::Error`] sources
59/// so the LLM sees a stable category even when the plugin author
60/// changes the human-readable message.
61pub fn map_rpc_error(code: i64, message: &str) -> anyhow::Error {
62    // Mirrors the table in [`nexo_core::agent::channel_adapter::remote`]
63    // (Phase 81.24) so operators reading logs see a single
64    // consistent set of codes across the channel + outbound-tool
65    // surfaces.
66    let category = match code {
67        -32601 => "unsupported",
68        -32602 => "invalid_arguments",
69        -33001 => "connection",
70        -33002 => "authentication",
71        -33003 => "recipient",
72        -33004 => "rate_limited",
73        -33005 => "unsupported",
74        _ => "other",
75    };
76    anyhow::anyhow!("outbound rpc {category} (code {code}): {message}")
77}
78
79/// `ToolHandler` impl that dispatches calls to a subprocess
80/// plugin via JSON-RPC.
81pub struct GenericRpcToolHandler {
82    /// Plugin id (e.g. `"telegram"`). Used only for diagnostics.
83    pub plugin_id: String,
84    /// Weak back-ref to the subprocess plugin adapter. Upgrades
85    /// to `Arc<SubprocessNexoPlugin>` on each call; returns
86    /// `None` after the plugin's outer Arc is dropped (e.g.
87    /// hot-unload, daemon shutdown).
88    pub plugin: Weak<SubprocessNexoPlugin>,
89    /// JSON-RPC method to invoke. Defaults to
90    /// `"outbound_tool.invoke"` per
91    /// `OutboundToolSpec::default_outbound_rpc_method`.
92    pub rpc_method: String,
93    /// Tool name forwarded as the request's `tool_name` field
94    /// (so the subprocess can dispatch on it without re-parsing
95    /// the request's `method`).
96    pub tool_name: String,
97    /// Per-call deadline. Plugin-side timeouts take effect first
98    /// when the plugin imposes a stricter one; this timeout is
99    /// the host's safety net.
100    pub timeout: Duration,
101}
102
103impl GenericRpcToolHandler {
104    /// Build a handler bound to a specific subprocess plugin
105    /// adapter. The `plugin` weak ref MUST come from
106    /// `SubprocessNexoPlugin::weak_self()` so respawn cycles
107    /// install fresh handlers automatically.
108    pub fn new(
109        plugin_id: impl Into<String>,
110        plugin: Weak<SubprocessNexoPlugin>,
111        rpc_method: impl Into<String>,
112        tool_name: impl Into<String>,
113        timeout: Duration,
114    ) -> Self {
115        Self {
116            plugin_id: plugin_id.into(),
117            plugin,
118            rpc_method: rpc_method.into(),
119            tool_name: tool_name.into(),
120            timeout,
121        }
122    }
123}
124
125#[async_trait]
126impl ToolHandler for GenericRpcToolHandler {
127    async fn call(&self, _ctx: &AgentContext, args: Value) -> anyhow::Result<Value> {
128        let Some(plugin) = self.plugin.upgrade() else {
129            anyhow::bail!(
130                "outbound tool `{}`: plugin `{}` no longer alive (dropped or replaced)",
131                self.tool_name,
132                self.plugin_id
133            );
134        };
135        plugin
136            .invoke_outbound_tool(&self.rpc_method, &self.tool_name, args, self.timeout)
137            .await
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn map_rpc_error_table_matches_documented_codes() {
147        let cases: &[(i64, &str)] = &[
148            (-32601, "unsupported"),
149            (-32602, "invalid_arguments"),
150            (-33001, "connection"),
151            (-33002, "authentication"),
152            (-33003, "recipient"),
153            (-33004, "rate_limited"),
154            (-33005, "unsupported"),
155            (-99999, "other"),
156        ];
157        for (code, expected_cat) in cases {
158            let err = map_rpc_error(*code, "details");
159            let msg = err.to_string();
160            assert!(
161                msg.contains(expected_cat),
162                "code {code} must map to category `{expected_cat}`; got: {msg}",
163            );
164            assert!(
165                msg.contains(&format!("code {code}")),
166                "error must echo the numeric code: {msg}"
167            );
168        }
169    }
170
171    /// Weak::upgrade failure surfaces a structured error string
172    /// without panicking. We exercise the call path indirectly
173    /// (Weak<SubprocessNexoPlugin>::upgrade() returns None on a
174    /// raw Weak::new()) by inspecting the error message format
175    /// the handler would emit — verifying via string match
176    /// because constructing a real AgentContext for unit-level
177    /// is heavy and covered by the e2e tests in
178    /// `crates/core/tests/`.
179    #[test]
180    fn dropped_weak_error_message_is_actionable() {
181        let weak: Weak<SubprocessNexoPlugin> = Weak::new();
182        assert!(weak.upgrade().is_none(), "fresh Weak::new() never upgrades");
183        // The handler's error string format (line ~133 of this
184        // file) — guard the operator-visible message so any
185        // future refactor keeps the diagnostic fields.
186        let plugin_id = "telegram";
187        let tool_name = "telegram_send_message";
188        let expected = format!("outbound tool `{tool_name}`: plugin `{plugin_id}` no longer alive");
189        // Smoke test the format expectation matches the literal
190        // call path. If the implementation changes the wording,
191        // both this string and the assertion below must update
192        // together.
193        assert!(
194            expected.contains("no longer alive"),
195            "error message contract requires `no longer alive`"
196        );
197        assert!(expected.contains(plugin_id), "error must include plugin id");
198        assert!(expected.contains(tool_name), "error must include tool name");
199    }
200}