leviath_runtime/
interaction_hub.rs1use std::collections::HashMap;
16use std::sync::{Arc, Mutex, OnceLock, PoisonError};
17
18use bevy_ecs::prelude::Resource;
19use leviath_core::interaction::{InteractionRequest, InteractionResponse};
20use tokio::sync::{Notify, oneshot};
21
22use crate::dynamic_interaction::InteractionBackend;
23
24struct PendingEntry {
26 agent_id: String,
28 request: InteractionRequest,
30 responder: oneshot::Sender<InteractionResponse>,
32}
33
34#[derive(Clone, Default, Resource)]
39pub struct InteractionHub {
40 pending: Arc<Mutex<HashMap<String, PendingEntry>>>,
41 wake: Arc<OnceLock<Arc<Notify>>>,
46}
47
48impl InteractionHub {
49 pub fn new() -> Self {
51 Self::default()
52 }
53
54 pub fn attach_wake(&self, wake: Arc<Notify>) {
57 let _ = self.wake.set(wake);
58 }
59
60 fn nudge(&self) {
62 if let Some(wake) = self.wake.get() {
63 wake.notify_one();
64 }
65 }
66
67 async fn submit(&self, agent_id: &str, request: InteractionRequest) -> InteractionResponse {
70 let id = request.id.clone();
71 let (responder, rx) = oneshot::channel();
72 self.pending
73 .lock()
74 .unwrap_or_else(PoisonError::into_inner)
75 .insert(
76 id.clone(),
77 PendingEntry {
78 agent_id: agent_id.to_string(),
79 request,
80 responder,
81 },
82 );
83 self.nudge();
86 rx.await
88 .unwrap_or_else(|_| InteractionResponse::text(id, ""))
89 }
90
91 pub fn pending(&self) -> Vec<(String, InteractionRequest)> {
94 self.pending
95 .lock()
96 .unwrap_or_else(PoisonError::into_inner)
97 .values()
98 .map(|e| (e.agent_id.clone(), e.request.clone()))
99 .collect()
100 }
101
102 pub fn answer(&self, response: InteractionResponse) -> bool {
105 let entry = self
106 .pending
107 .lock()
108 .unwrap_or_else(PoisonError::into_inner)
109 .remove(&response.request_id);
110 match entry {
111 Some(entry) => {
112 let _ = entry.responder.send(response);
115 self.nudge();
118 true
119 }
120 None => false,
121 }
122 }
123
124 pub fn cancel(&self, request_id: &str) -> bool {
127 let removed = self
129 .pending
130 .lock()
131 .unwrap_or_else(PoisonError::into_inner)
132 .remove(request_id)
133 .is_some();
134 if removed {
135 self.nudge();
136 }
137 removed
138 }
139
140 pub fn cancel_for_agent(&self, agent_id: &str) -> usize {
151 let mut pending = self.pending.lock().unwrap_or_else(PoisonError::into_inner);
153 let before = pending.len();
154 pending.retain(|_, entry| entry.agent_id != agent_id);
155 let removed = before - pending.len();
156 drop(pending);
157 if removed > 0 {
158 self.nudge();
159 }
160 removed
161 }
162
163 pub fn backend_for(&self, agent_id: impl Into<String>) -> HubInteractionBackend {
165 HubInteractionBackend {
166 hub: self.clone(),
167 agent_id: agent_id.into(),
168 }
169 }
170}
171
172#[derive(Clone)]
175pub struct HubInteractionBackend {
176 hub: InteractionHub,
177 agent_id: String,
178}
179
180#[async_trait::async_trait]
181impl InteractionBackend for HubInteractionBackend {
182 async fn ask(&self, request: InteractionRequest) -> InteractionResponse {
183 self.hub.submit(&self.agent_id, request).await
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 fn req(id: &str) -> InteractionRequest {
192 InteractionRequest::free_text(id, "prompt?", "stage", true)
193 }
194
195 async fn settle() {
199 for _ in 0..8 {
200 tokio::task::yield_now().await;
201 }
202 }
203
204 #[test]
205 fn a_poisoned_registry_still_serves_every_other_agent() {
206 let hub = InteractionHub::new();
211 let prev = std::panic::take_hook();
212 std::panic::set_hook(Box::new(|_| {})); let poisoned = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
214 let _guard = hub.pending.lock().expect("fresh lock");
215 panic!("a panic while holding the interaction registry");
216 }));
217 std::panic::set_hook(prev);
218 assert!(poisoned.is_err());
219 assert!(hub.pending.is_poisoned(), "the lock really is poisoned");
220
221 assert!(hub.pending().is_empty());
222 assert!(!hub.cancel("nope"));
223 assert!(!hub.answer(InteractionResponse::text("nope", "x")));
224 }
225
226 #[tokio::test]
227 async fn ask_is_answered_through_the_hub() {
228 let hub = InteractionHub::new();
229 let backend = hub.backend_for("agent-a");
230 let asking = tokio::spawn(async move { backend.ask(req("q1")).await });
231
232 settle().await;
233 let pending = hub.pending();
234 assert_eq!(pending.len(), 1);
235 assert_eq!(pending[0].0, "agent-a");
236 assert_eq!(pending[0].1.id, "q1");
237
238 assert!(hub.answer(InteractionResponse::text("q1", "hello")));
239 let response = asking.await.unwrap();
240 assert_eq!(response.value.as_deref(), Some("hello"));
241 assert!(hub.pending().is_empty());
243 }
244
245 #[tokio::test]
246 async fn answer_unknown_request_is_false() {
247 let hub = InteractionHub::new();
248 assert!(!hub.answer(InteractionResponse::text("nope", "x")));
249 }
250
251 #[tokio::test]
252 async fn submit_and_answer_nudge_the_attached_wake() {
253 let hub = InteractionHub::new();
254 let wake = Arc::new(Notify::new());
255 hub.attach_wake(wake.clone());
256 hub.attach_wake(Arc::new(Notify::new()));
258
259 let backend = hub.backend_for("agent-a");
260 let asking = tokio::spawn(async move { backend.ask(req("q1")).await });
261 settle().await;
262
263 wake.notified().await;
265
266 assert!(hub.answer(InteractionResponse::text("q1", "hi")));
268 wake.notified().await;
269 assert_eq!(asking.await.unwrap().value.as_deref(), Some("hi"));
270 }
271
272 #[tokio::test]
273 async fn cancel_nudges_the_attached_wake() {
274 let hub = InteractionHub::new();
275 let wake = Arc::new(Notify::new());
276 hub.attach_wake(wake.clone());
277
278 let backend = hub.backend_for("agent-a");
279 let asking = tokio::spawn(async move { backend.ask(req("q2")).await });
280 settle().await;
281 wake.notified().await; assert!(hub.cancel("q2"));
284 wake.notified().await; let _ = asking.await.unwrap();
286 }
287
288 #[tokio::test]
289 async fn cancel_wakes_submit_with_neutral_response() {
290 let hub = InteractionHub::new();
291 let backend = hub.backend_for("agent-a");
292 let asking = tokio::spawn(async move { backend.ask(req("q2")).await });
293
294 settle().await;
295 assert!(hub.cancel("q2"));
296 let response = asking.await.unwrap();
297 assert_eq!(response.request_id, "q2");
298 assert_eq!(response.value.as_deref(), Some("")); assert!(!hub.cancel("q2"));
302 }
303}