Skip to main content

nexo_core/agent/
routing.rs

1use dashmap::DashMap;
2use nexo_broker::{AnyBroker, BrokerHandle, Event};
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::sync::Arc;
6use std::time::Duration;
7use tokio::sync::oneshot;
8use tokio::time::timeout;
9use uuid::Uuid;
10
11/// RAII cleaner for a pending correlation entry. Ensures the entry is
12/// removed on drop — including when the `delegate` future is cancelled
13/// mid-await, which the earlier manual `.remove()` at each explicit
14/// exit could not cover.
15struct PendingGuard {
16    map: Arc<DashMap<Uuid, oneshot::Sender<Value>>>,
17    id: Uuid,
18    armed: bool,
19}
20
21impl PendingGuard {
22    fn disarm(mut self) {
23        self.armed = false;
24    }
25}
26
27impl Drop for PendingGuard {
28    fn drop(&mut self) {
29        if self.armed {
30            self.map.remove(&self.id);
31        }
32    }
33}
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct AgentMessage {
36    pub from: String,
37    pub to: String,
38    pub correlation_id: Uuid,
39    pub payload: AgentPayload,
40}
41#[derive(Debug, Clone, Serialize, Deserialize)]
42#[serde(tag = "kind", rename_all = "snake_case")]
43pub enum AgentPayload {
44    Delegate {
45        task: String,
46        #[serde(default)]
47        context: Value,
48    },
49    Result {
50        task_id: Uuid,
51        output: Value,
52    },
53    Broadcast {
54        event: String,
55        data: Value,
56    },
57}
58#[derive(Default)]
59pub struct AgentRouter {
60    pending: Arc<DashMap<Uuid, oneshot::Sender<Value>>>,
61}
62impl AgentRouter {
63    pub fn new() -> Self {
64        Self::default()
65    }
66    pub async fn delegate(
67        &self,
68        broker: &AnyBroker,
69        from: &str,
70        to: &str,
71        task: &str,
72        mut context: Value,
73        timeout_ms: u64,
74    ) -> anyhow::Result<Value> {
75        // Cycle guard — context carries `_delegate_chain: [from1, from2,
76        // …]`. If `to` already appears in the chain, delegating would
77        // deadlock both agents waiting on each other's reply. Bail
78        // before any broker publish so the pending map stays clean.
79        let chain_key = "_delegate_chain";
80        let chain: Vec<String> = context
81            .get(chain_key)
82            .and_then(|v| v.as_array())
83            .map(|arr| {
84                arr.iter()
85                    .filter_map(|v| v.as_str().map(str::to_string))
86                    .collect()
87            })
88            .unwrap_or_default();
89        if chain.iter().any(|a| a == to) {
90            anyhow::bail!(
91                "delegate cycle detected: {to} already in chain [{}]",
92                chain.join(" → ")
93            );
94        }
95        let mut new_chain = chain.clone();
96        new_chain.push(from.to_string());
97        if let Some(map) = context.as_object_mut() {
98            map.insert(
99                chain_key.to_string(),
100                Value::Array(new_chain.into_iter().map(Value::String).collect()),
101            );
102        } else {
103            // Non-object context — wrap so we can attach the chain.
104            context = serde_json::json!({
105                chain_key: new_chain,
106                "original": context,
107            });
108        }
109
110        let correlation_id = Uuid::new_v4();
111        let (tx, rx) = oneshot::channel();
112        self.pending.insert(correlation_id, tx);
113        // RAII guard ensures the entry leaves the map even if the
114        // caller cancels this future before the timeout fires.
115        let guard = PendingGuard {
116            map: Arc::clone(&self.pending),
117            id: correlation_id,
118            armed: true,
119        };
120        let msg = AgentMessage {
121            from: from.to_string(),
122            to: to.to_string(),
123            correlation_id,
124            payload: AgentPayload::Delegate {
125                task: task.to_string(),
126                context,
127            },
128        };
129        let topic = route_topic(to);
130        let payload = serde_json::to_value(msg)?;
131        let event = Event::new(&topic, from, payload);
132        if let Err(e) = broker.publish(&topic, event).await {
133            return Err(e.into());
134        }
135        match timeout(Duration::from_millis(timeout_ms), rx).await {
136            Ok(Ok(output)) => {
137                // resolve() already removed the entry when it sent;
138                // keep the guard armed-as-no-op doesn't matter.
139                guard.disarm();
140                Ok(output)
141            }
142            Ok(Err(_)) => {
143                anyhow::bail!(
144                    "delegate response channel dropped for correlation_id={correlation_id}"
145                );
146            }
147            Err(_) => {
148                anyhow::bail!(
149                    "delegate timed out after {timeout_ms}ms (correlation_id={correlation_id})"
150                );
151            }
152        }
153    }
154    pub fn resolve(&self, correlation_id: Uuid, output: Value) -> bool {
155        let Some((_, tx)) = self.pending.remove(&correlation_id) else {
156            return false;
157        };
158        tx.send(output).is_ok()
159    }
160
161    /// Cleanup helper — returns the current pending entry count. Used
162    /// by tests and health checks to verify the map doesn't leak.
163    pub fn pending_count(&self) -> usize {
164        self.pending.len()
165    }
166}
167pub fn route_topic(agent_id: &str) -> String {
168    format!("agent.route.{agent_id}")
169}
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[tokio::test]
175    async fn cancelled_delegate_cleans_pending_map() {
176        // Spawn delegate on a task so we can `abort()` it cleanly —
177        // dropping the pinned future inline can leave the underlying
178        // future parked in place even after the `Pin` wrapper is
179        // dropped. An aborted task is guaranteed to release state.
180        let router = Arc::new(AgentRouter::new());
181        let broker = nexo_broker::AnyBroker::local();
182        let router_in = Arc::clone(&router);
183        let broker_in = broker.clone();
184        let handle = tokio::spawn(async move {
185            router_in
186                .delegate(
187                    &broker_in,
188                    "kate",
189                    "research",
190                    "task",
191                    serde_json::json!({}),
192                    60_000,
193                )
194                .await
195        });
196        // Let delegate run past publish into the timeout.
197        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
198        assert_eq!(router.pending_count(), 1, "guard should have inserted");
199        handle.abort();
200        let _ = handle.await;
201        assert_eq!(router.pending_count(), 0, "pending entry leaked");
202    }
203
204    #[tokio::test]
205    async fn delegate_cycle_is_rejected() {
206        let router = AgentRouter::new();
207        let broker = nexo_broker::AnyBroker::local();
208        // Simulate A → B delegation that's now about to route back to A
209        // (B calling delegate(to="A") with the inherited chain).
210        let ctx = serde_json::json!({ "_delegate_chain": ["A"] });
211        let err = router
212            .delegate(&broker, "B", "A", "task", ctx, 1000)
213            .await
214            .unwrap_err();
215        let msg = format!("{err:#}");
216        assert!(msg.contains("cycle detected"), "got: {msg}");
217        assert_eq!(router.pending_count(), 0);
218    }
219
220    #[test]
221    fn agent_message_serde_round_trip() {
222        let msg = AgentMessage {
223            from: "kate".to_string(),
224            to: "research".to_string(),
225            correlation_id: Uuid::new_v4(),
226            payload: AgentPayload::Delegate {
227                task: "find latest updates".to_string(),
228                context: serde_json::json!({"priority":"high"}),
229            },
230        };
231        let json = serde_json::to_string(&msg).unwrap();
232        let decoded: AgentMessage = serde_json::from_str(&json).unwrap();
233        assert_eq!(decoded.from, "kate");
234        assert_eq!(decoded.to, "research");
235        match decoded.payload {
236            AgentPayload::Delegate { task, context } => {
237                assert_eq!(task, "find latest updates");
238                assert_eq!(context["priority"], "high");
239            }
240            _ => panic!("expected delegate payload"),
241        }
242    }
243}