nexo-core 0.2.1

Agent runtime: event bus, sessions, plugin trait, heartbeat, A2A delegation.
Documentation
//! Phase 81.33.a — generic RPC tool handler.
//!
//! `GenericRpcToolHandler` implements [`ToolHandler`] by translating
//! each LLM tool call into a JSON-RPC request against a subprocess
//! plugin (Phase 81.14.b stdio bridge). It is the daemon-side
//! counterpart to the plugin-side `outbound_tool.invoke` handler
//! declared in `nexo-plugin.toml::[[plugin.tools.outbound]]`.
//!
//! ## Why
//!
//! Before Phase 81.33, the daemon hardcoded
//! `nexo_plugin_whatsapp::register_whatsapp_tools(&tools)` /
//! `nexo_plugin_telegram::register_telegram_tools(&tools)` /
//! etc. at boot. That meant every new channel (slack, discord,
//! sms, instagram, …) required editing `src/main.rs` AND adding
//! a daemon Cargo.toml dep on the plugin crate. This couples the
//! daemon to a fixed plugin set and prevents community-tier
//! out-of-tree plugins from shipping outbound tools.
//!
//! With this handler:
//!
//!   1. Plugin declares `[[plugin.tools.outbound]]` entries in its
//!      own manifest (name + description + JSON schema + RPC
//!      method).
//!   2. `SubprocessNexoPlugin::register_outbound_tools` iterates
//!      the manifest and installs one `GenericRpcToolHandler` per
//!      entry against the per-agent `ToolRegistry`.
//!   3. LLM calls the tool → handler serialises
//!      `{"tool_name":"...", "args":{...}}` into a JSON-RPC
//!      request → subprocess dispatches → result flows back.
//!
//! The daemon depends on **zero plugin-specific crates** for the
//! outbound-tool hot path.
//!
//! ## Respawn semantics
//!
//! The handler holds `Weak<SubprocessNexoPlugin>` (NOT `Arc`) so
//! a respawned plugin instance gets a fresh registration without
//! the old handler keeping the dead `Inner` alive. After
//! supervisor respawn, the per-agent `ToolRegistry` is rebuilt
//! at the next hot-spawn / reload, installing handlers against
//! the new `Inner`'s `pending` + `stdin_tx`.

use std::sync::Weak;
use std::time::Duration;

use async_trait::async_trait;
use serde_json::Value;

use crate::agent::nexo_plugin_registry::subprocess::SubprocessNexoPlugin;
use crate::agent::tool_registry::ToolHandler;
use crate::agent::AgentContext;

/// Default outbound-tool call timeout when the manifest doesn't
/// override + `NEXO_PLUGIN_TOOL_TIMEOUT_MS` env is unset.
pub const DEFAULT_OUTBOUND_TOOL_TIMEOUT: Duration = Duration::from_secs(60);

/// JSON-RPC error codes mapped to typed [`anyhow::Error`] sources
/// so the LLM sees a stable category even when the plugin author
/// changes the human-readable message.
pub fn map_rpc_error(code: i64, message: &str) -> anyhow::Error {
    // Mirrors the table in [`nexo_core::agent::channel_adapter::remote`]
    // (Phase 81.24) so operators reading logs see a single
    // consistent set of codes across the channel + outbound-tool
    // surfaces.
    let category = match code {
        -32601 => "unsupported",
        -32602 => "invalid_arguments",
        -33001 => "connection",
        -33002 => "authentication",
        -33003 => "recipient",
        -33004 => "rate_limited",
        -33005 => "unsupported",
        _ => "other",
    };
    anyhow::anyhow!("outbound rpc {category} (code {code}): {message}")
}

/// `ToolHandler` impl that dispatches calls to a subprocess
/// plugin via JSON-RPC.
pub struct GenericRpcToolHandler {
    /// Plugin id (e.g. `"telegram"`). Used only for diagnostics.
    pub plugin_id: String,
    /// Weak back-ref to the subprocess plugin adapter. Upgrades
    /// to `Arc<SubprocessNexoPlugin>` on each call; returns
    /// `None` after the plugin's outer Arc is dropped (e.g.
    /// hot-unload, daemon shutdown).
    pub plugin: Weak<SubprocessNexoPlugin>,
    /// JSON-RPC method to invoke. Defaults to
    /// `"outbound_tool.invoke"` per
    /// `OutboundToolSpec::default_outbound_rpc_method`.
    pub rpc_method: String,
    /// Tool name forwarded as the request's `tool_name` field
    /// (so the subprocess can dispatch on it without re-parsing
    /// the request's `method`).
    pub tool_name: String,
    /// Per-call deadline. Plugin-side timeouts take effect first
    /// when the plugin imposes a stricter one; this timeout is
    /// the host's safety net.
    pub timeout: Duration,
}

impl GenericRpcToolHandler {
    /// Build a handler bound to a specific subprocess plugin
    /// adapter. The `plugin` weak ref MUST come from
    /// `SubprocessNexoPlugin::weak_self()` so respawn cycles
    /// install fresh handlers automatically.
    pub fn new(
        plugin_id: impl Into<String>,
        plugin: Weak<SubprocessNexoPlugin>,
        rpc_method: impl Into<String>,
        tool_name: impl Into<String>,
        timeout: Duration,
    ) -> Self {
        Self {
            plugin_id: plugin_id.into(),
            plugin,
            rpc_method: rpc_method.into(),
            tool_name: tool_name.into(),
            timeout,
        }
    }
}

#[async_trait]
impl ToolHandler for GenericRpcToolHandler {
    async fn call(&self, _ctx: &AgentContext, args: Value) -> anyhow::Result<Value> {
        let Some(plugin) = self.plugin.upgrade() else {
            anyhow::bail!(
                "outbound tool `{}`: plugin `{}` no longer alive (dropped or replaced)",
                self.tool_name,
                self.plugin_id
            );
        };
        plugin
            .invoke_outbound_tool(&self.rpc_method, &self.tool_name, args, self.timeout)
            .await
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn map_rpc_error_table_matches_documented_codes() {
        let cases: &[(i64, &str)] = &[
            (-32601, "unsupported"),
            (-32602, "invalid_arguments"),
            (-33001, "connection"),
            (-33002, "authentication"),
            (-33003, "recipient"),
            (-33004, "rate_limited"),
            (-33005, "unsupported"),
            (-99999, "other"),
        ];
        for (code, expected_cat) in cases {
            let err = map_rpc_error(*code, "details");
            let msg = err.to_string();
            assert!(
                msg.contains(expected_cat),
                "code {code} must map to category `{expected_cat}`; got: {msg}",
            );
            assert!(
                msg.contains(&format!("code {code}")),
                "error must echo the numeric code: {msg}"
            );
        }
    }

    /// Weak::upgrade failure surfaces a structured error string
    /// without panicking. We exercise the call path indirectly
    /// (Weak<SubprocessNexoPlugin>::upgrade() returns None on a
    /// raw Weak::new()) by inspecting the error message format
    /// the handler would emit — verifying via string match
    /// because constructing a real AgentContext for unit-level
    /// is heavy and covered by the e2e tests in
    /// `crates/core/tests/`.
    #[test]
    fn dropped_weak_error_message_is_actionable() {
        let weak: Weak<SubprocessNexoPlugin> = Weak::new();
        assert!(weak.upgrade().is_none(), "fresh Weak::new() never upgrades");
        // The handler's error string format (line ~133 of this
        // file) — guard the operator-visible message so any
        // future refactor keeps the diagnostic fields.
        let plugin_id = "telegram";
        let tool_name = "telegram_send_message";
        let expected = format!("outbound tool `{tool_name}`: plugin `{plugin_id}` no longer alive");
        // Smoke test the format expectation matches the literal
        // call path. If the implementation changes the wording,
        // both this string and the assertion below must update
        // together.
        assert!(
            expected.contains("no longer alive"),
            "error message contract requires `no longer alive`"
        );
        assert!(expected.contains(plugin_id), "error must include plugin id");
        assert!(expected.contains(tool_name), "error must include tool name");
    }
}