Skip to main content

nexo_core/agent/
hook_remote.rs

1//! `HookHandler` impl backed by a subprocess plugin's stdio bridge.
2//!
3//! Subprocess plugins declaring `[plugin.extends].hooks =
4//! ["before_message", "after_message"]` get one
5//! `RemoteHookHandler` per hook name registered into the
6//! daemon's `HookRegistry`. When the daemon fires that hook,
7//! the handler translates the call into a `hook.on_hook`
8//! JSON-RPC request over the subprocess plugin's stdio bridge
9//! (the same `Inner.{stdin_tx, pending, next_id}` shared with
10//! `RemoteChannelAdapter` and `RemoteLlmClient`).
11//!
12//! Continue-on-error: every dispatch failure (transport,
13//! subprocess crash, timeout, malformed reply, JSON-RPC error)
14//! returns `Ok(HookResponse::default())`. Reason: `HookRegistry`
15//! must keep iterating handlers + agent flow must not break on
16//! subprocess misbehavior. Errors land in `tracing::warn!` so
17//! operators can debug without a hard failure.
18//!
19//! Hooks fire on the message hot path; default timeout is 5 s
20//! (lower than 81.24's 30 s for channels and 81.25's 60 s for
21//! LLMs). Operator override via `NEXO_PLUGIN_HOOK_TIMEOUT_MS`.
22
23use std::sync::atomic::{AtomicU64, Ordering};
24use std::sync::Arc;
25use std::time::Duration;
26
27use async_trait::async_trait;
28use dashmap::DashMap;
29use nexo_extensions::HookResponse;
30use serde_json::Value;
31use tokio::sync::{mpsc, oneshot};
32
33use crate::agent::hook_registry::HookHandler;
34
35const DEFAULT_HOOK_TIMEOUT: Duration = Duration::from_secs(5);
36
37/// `HookHandler` impl backed by a subprocess plugin. One
38/// instance is registered per hook name listed in
39/// `manifest.plugin.extends.hooks`.
40pub struct RemoteHookHandler {
41    hook_name: String,
42    plugin_id: String,
43    stdin_tx: mpsc::Sender<Value>,
44    pending: Arc<DashMap<u64, oneshot::Sender<Result<Value, String>>>>,
45    next_id: Arc<AtomicU64>,
46    request_timeout: Duration,
47}
48
49impl RemoteHookHandler {
50    pub fn new(
51        hook_name: String,
52        plugin_id: String,
53        stdin_tx: mpsc::Sender<Value>,
54        pending: Arc<DashMap<u64, oneshot::Sender<Result<Value, String>>>>,
55        next_id: Arc<AtomicU64>,
56    ) -> Self {
57        Self {
58            hook_name,
59            plugin_id,
60            stdin_tx,
61            pending,
62            next_id,
63            request_timeout: Self::resolve_timeout(),
64        }
65    }
66
67    fn resolve_timeout() -> Duration {
68        std::env::var("NEXO_PLUGIN_HOOK_TIMEOUT_MS")
69            .ok()
70            .and_then(|s| s.parse::<u64>().ok())
71            .map(Duration::from_millis)
72            .unwrap_or(DEFAULT_HOOK_TIMEOUT)
73    }
74
75    pub fn plugin_id(&self) -> &str {
76        &self.plugin_id
77    }
78
79    pub fn hook_name(&self) -> &str {
80        &self.hook_name
81    }
82}
83
84#[async_trait]
85impl HookHandler for RemoteHookHandler {
86    async fn on_hook(&self, name: &str, event: Value) -> anyhow::Result<HookResponse> {
87        let id = self.next_id.fetch_add(1, Ordering::SeqCst);
88        let frame = serde_json::json!({
89            "jsonrpc": "2.0",
90            "id": id,
91            "method": "hook.on_hook",
92            "params": {
93                "plugin_id": &self.plugin_id,
94                "hook_name": name,
95                "event": event,
96            },
97        });
98        let (tx, rx) = oneshot::channel();
99        self.pending.insert(id, tx);
100
101        if self.stdin_tx.send(frame).await.is_err() {
102            self.pending.remove(&id);
103            tracing::warn!(
104                plugin = %self.plugin_id,
105                hook = %self.hook_name,
106                "hook.on_hook stdin send failed — Continue"
107            );
108            return Ok(HookResponse::default());
109        }
110
111        match tokio::time::timeout(self.request_timeout, rx).await {
112            Ok(Ok(Ok(value))) => match serde_json::from_value::<HookResponse>(value) {
113                Ok(resp) => Ok(resp),
114                Err(e) => {
115                    tracing::warn!(
116                        plugin = %self.plugin_id,
117                        hook = %self.hook_name,
118                        error = %e,
119                        "hook.on_hook reply decode failed — Continue"
120                    );
121                    Ok(HookResponse::default())
122                }
123            },
124            Ok(Ok(Err(err_str))) => {
125                tracing::warn!(
126                    plugin = %self.plugin_id,
127                    hook = %self.hook_name,
128                    error = %err_str,
129                    "hook.on_hook returned error — Continue"
130                );
131                Ok(HookResponse::default())
132            }
133            Ok(Err(_)) => {
134                self.pending.remove(&id);
135                tracing::warn!(
136                    plugin = %self.plugin_id,
137                    hook = %self.hook_name,
138                    "hook.on_hook pending dropped (subprocess gone) — Continue"
139                );
140                Ok(HookResponse::default())
141            }
142            Err(_) => {
143                self.pending.remove(&id);
144                tracing::warn!(
145                    plugin = %self.plugin_id,
146                    hook = %self.hook_name,
147                    timeout_ms = self.request_timeout.as_millis() as u64,
148                    "hook.on_hook timed out — Continue"
149                );
150                Ok(HookResponse::default())
151            }
152        }
153    }
154}
155
156/// Errors surfaced by `register_remote_hook_handlers`.
157/// `HookRegistry::register` itself never fails (cap-violations
158/// log + skip silently), so the only failure mode here is
159/// `Inner` not being initialized.
160#[derive(Debug, thiserror::Error)]
161pub enum HookHandlerRegistrationError {
162    #[error(
163        "subprocess plugin inner not initialized — call register_remote_hook_handlers AFTER init()"
164    )]
165    InnerUnavailable,
166}
167
168// ── Tests ────────────────────────────────────────────────────────
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    fn build() -> (
175        Arc<RemoteHookHandler>,
176        mpsc::Receiver<Value>,
177        Arc<DashMap<u64, oneshot::Sender<Result<Value, String>>>>,
178    ) {
179        let (stdin_tx, stdin_rx) = mpsc::channel(8);
180        let pending: Arc<DashMap<u64, oneshot::Sender<Result<Value, String>>>> =
181            Arc::new(DashMap::new());
182        let next_id = Arc::new(AtomicU64::new(1));
183        let handler = Arc::new(RemoteHookHandler::new(
184            "before_message".to_string(),
185            "mock_plugin".to_string(),
186            stdin_tx,
187            pending.clone(),
188            next_id,
189        ));
190        (handler, stdin_rx, pending)
191    }
192
193    fn resolve_with_result(
194        pending: &DashMap<u64, oneshot::Sender<Result<Value, String>>>,
195        id: u64,
196        result: Value,
197    ) {
198        if let Some((_, sender)) = pending.remove(&id) {
199            let _ = sender.send(Ok(result));
200        }
201    }
202
203    fn resolve_with_error(
204        pending: &DashMap<u64, oneshot::Sender<Result<Value, String>>>,
205        id: u64,
206        err_obj: Value,
207    ) {
208        if let Some((_, sender)) = pending.remove(&id) {
209            let _ = sender.send(Err(err_obj.to_string()));
210        }
211    }
212
213    #[tokio::test]
214    async fn on_hook_serializes_request_with_hook_name_and_event() {
215        let (handler, mut stdin_rx, pending) = build();
216        let task = tokio::spawn({
217            let handler = handler.clone();
218            async move {
219                handler
220                    .on_hook("before_message", serde_json::json!({"sender":"alice"}))
221                    .await
222            }
223        });
224
225        let frame = stdin_rx.recv().await.expect("frame");
226        assert_eq!(frame["method"], "hook.on_hook");
227        assert_eq!(frame["params"]["plugin_id"], "mock_plugin");
228        assert_eq!(frame["params"]["hook_name"], "before_message");
229        assert_eq!(frame["params"]["event"]["sender"], "alice");
230        let id = frame["id"].as_u64().unwrap();
231        resolve_with_result(&pending, id, serde_json::json!({}));
232
233        let resp = task.await.unwrap().unwrap();
234        assert!(!resp.abort);
235        assert_eq!(resp.decision, None);
236    }
237
238    #[tokio::test]
239    async fn on_hook_deserializes_response_with_decision() {
240        let (handler, mut stdin_rx, pending) = build();
241        let task = tokio::spawn({
242            let handler = handler.clone();
243            async move {
244                handler
245                    .on_hook("before_message", serde_json::json!({}))
246                    .await
247            }
248        });
249
250        let frame = stdin_rx.recv().await.expect("frame");
251        let id = frame["id"].as_u64().unwrap();
252        resolve_with_result(
253            &pending,
254            id,
255            serde_json::json!({
256                "abort": true,
257                "reason": "PII detected",
258                "decision": "block"
259            }),
260        );
261
262        let resp = task.await.unwrap().unwrap();
263        assert!(resp.abort);
264        assert_eq!(resp.reason.as_deref(), Some("PII detected"));
265        assert_eq!(resp.decision.as_deref(), Some("block"));
266    }
267
268    #[tokio::test]
269    async fn on_hook_unsupported_method_returns_continue() {
270        let (handler, mut stdin_rx, pending) = build();
271        let task = tokio::spawn({
272            let handler = handler.clone();
273            async move {
274                handler
275                    .on_hook("before_message", serde_json::json!({}))
276                    .await
277            }
278        });
279
280        let frame = stdin_rx.recv().await.expect("frame");
281        let id = frame["id"].as_u64().unwrap();
282        resolve_with_error(
283            &pending,
284            id,
285            serde_json::json!({
286                "code": -32601,
287                "message": "hook.on_hook"
288            }),
289        );
290
291        // Continue-on-error: returns Ok(default), NOT Err.
292        let resp = task.await.unwrap().unwrap();
293        assert_eq!(resp, HookResponse::default());
294    }
295
296    #[tokio::test(flavor = "current_thread", start_paused = true)]
297    async fn on_hook_timeout_returns_continue() {
298        let (stdin_tx, mut stdin_rx) = mpsc::channel(8);
299        let pending: Arc<DashMap<u64, oneshot::Sender<Result<Value, String>>>> =
300            Arc::new(DashMap::new());
301        let next_id = Arc::new(AtomicU64::new(1));
302        let handler = RemoteHookHandler {
303            hook_name: "before_message".into(),
304            plugin_id: "mock_plugin".into(),
305            stdin_tx,
306            pending,
307            next_id,
308            request_timeout: Duration::from_millis(50),
309        };
310
311        let task = tokio::spawn(async move {
312            handler
313                .on_hook("before_message", serde_json::json!({}))
314                .await
315        });
316        let _frame = stdin_rx.recv().await.expect("frame");
317        // Don't resolve — let timeout fire.
318        tokio::time::advance(Duration::from_millis(200)).await;
319
320        let resp = task.await.unwrap().unwrap();
321        assert_eq!(resp, HookResponse::default());
322    }
323
324    #[tokio::test]
325    async fn on_hook_invalid_response_returns_continue() {
326        let (handler, mut stdin_rx, pending) = build();
327        let task = tokio::spawn({
328            let handler = handler.clone();
329            async move {
330                handler
331                    .on_hook("before_message", serde_json::json!({}))
332                    .await
333            }
334        });
335
336        let frame = stdin_rx.recv().await.expect("frame");
337        let id = frame["id"].as_u64().unwrap();
338        // Reply with a value that doesn't deserialize to HookResponse
339        // (e.g. an array — HookResponse expects an object).
340        resolve_with_result(&pending, id, serde_json::json!([1, 2, 3]));
341
342        let resp = task.await.unwrap().unwrap();
343        assert_eq!(resp, HookResponse::default());
344    }
345
346    #[tokio::test]
347    async fn on_hook_transform_decision_round_trips_transformed_body() {
348        let (handler, mut stdin_rx, pending) = build();
349        let task = tokio::spawn({
350            let handler = handler.clone();
351            async move {
352                handler
353                    .on_hook(
354                        "before_message",
355                        serde_json::json!({"body": "ssn 123-45-6789"}),
356                    )
357                    .await
358            }
359        });
360
361        let frame = stdin_rx.recv().await.expect("frame");
362        let id = frame["id"].as_u64().unwrap();
363        resolve_with_result(
364            &pending,
365            id,
366            serde_json::json!({
367                "decision": "transform",
368                "transformed_body": "ssn [REDACTED]"
369            }),
370        );
371
372        let resp = task.await.unwrap().unwrap();
373        assert_eq!(resp.decision.as_deref(), Some("transform"));
374        assert_eq!(resp.transformed_body.as_deref(), Some("ssn [REDACTED]"));
375    }
376
377    #[tokio::test]
378    async fn on_hook_do_not_reply_again_round_trips() {
379        let (handler, mut stdin_rx, pending) = build();
380        let task = tokio::spawn({
381            let handler = handler.clone();
382            async move {
383                handler
384                    .on_hook("after_message", serde_json::json!({}))
385                    .await
386            }
387        });
388
389        let frame = stdin_rx.recv().await.expect("frame");
390        let id = frame["id"].as_u64().unwrap();
391        resolve_with_result(
392            &pending,
393            id,
394            serde_json::json!({
395                "decision": "allow",
396                "do_not_reply_again": true
397            }),
398        );
399
400        let resp = task.await.unwrap().unwrap();
401        assert!(resp.do_not_reply_again);
402    }
403
404    #[tokio::test]
405    async fn on_hook_override_event_round_trips() {
406        let (handler, mut stdin_rx, pending) = build();
407        let task = tokio::spawn({
408            let handler = handler.clone();
409            async move {
410                handler
411                    .on_hook("before_message", serde_json::json!({"k": "v"}))
412                    .await
413            }
414        });
415
416        let frame = stdin_rx.recv().await.expect("frame");
417        let id = frame["id"].as_u64().unwrap();
418        resolve_with_result(
419            &pending,
420            id,
421            serde_json::json!({
422                "override": { "k": "rewritten" }
423            }),
424        );
425
426        let resp = task.await.unwrap().unwrap();
427        assert_eq!(
428            resp.override_event
429                .as_ref()
430                .and_then(|v| v.get("k"))
431                .and_then(|v| v.as_str()),
432            Some("rewritten")
433        );
434    }
435}