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
11struct 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 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 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 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 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 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 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 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 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}