Skip to main content

leviath_runtime/
interaction_hub.rs

1//! In-memory interaction hub - the shared-world replacement for the imperative
2//! worker's `pending.json`/`response.json` file polling.
3//!
4//! When an agent's tool execution needs human input (an `ask_user_*` /
5//! `present_for_review` tool, or a tool-approval prompt), its
6//! [`HubInteractionBackend::ask`] registers the [`InteractionRequest`] with the
7//! [`InteractionHub`] and awaits a oneshot for the answer. The daemon surfaces
8//! open requests over the control channel via [`InteractionHub::pending`] and
9//! delivers answers with [`InteractionHub::answer`] - no filesystem, no polling.
10//!
11//! `ask` blocks the calling tool worker until the request is answered or
12//! cancelled; with the tool lane sequential today that serializes interactive
13//! agents, which is acceptable (the lane becomes a pool later).
14
15use 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
24/// One open interaction awaiting an answer.
25struct PendingEntry {
26    /// The agent (by id) that raised the request.
27    agent_id: String,
28    /// The request itself (surfaced to clients).
29    request: InteractionRequest,
30    /// Fulfilled by [`InteractionHub::answer`]; dropped by [`InteractionHub::cancel`].
31    responder: oneshot::Sender<InteractionResponse>,
32}
33
34/// A process-wide registry of open interactions, keyed by request id. Cheap to
35/// clone (shared `Arc`). Also a bevy [`Resource`] so the tick loop's
36/// [`reflect_interaction_status`](crate::pipeline::reflect_interaction_status)
37/// system can mirror open requests into agent status.
38#[derive(Clone, Default, Resource)]
39pub struct InteractionHub {
40    pending: Arc<Mutex<HashMap<String, PendingEntry>>>,
41    /// The tick-loop wake handle, attached once by
42    /// [`PipelineWorld::insert_interaction_hub`](crate::world::PipelineWorld::insert_interaction_hub).
43    /// Opening, answering, or cancelling a request nudges it so the loop ticks
44    /// (while otherwise parked) and reflects the change into agent status.
45    wake: Arc<OnceLock<Arc<Notify>>>,
46}
47
48impl InteractionHub {
49    /// A fresh, empty hub.
50    pub fn new() -> Self {
51        Self::default()
52    }
53
54    /// Attach the tick-loop wake handle so registry changes wake the driver.
55    /// Idempotent: a second call is ignored (the handle is set once at startup).
56    pub fn attach_wake(&self, wake: Arc<Notify>) {
57        let _ = self.wake.set(wake);
58    }
59
60    /// Wake the tick loop if a handle is attached (no-op otherwise).
61    fn nudge(&self) {
62        if let Some(wake) = self.wake.get() {
63            wake.notify_one();
64        }
65    }
66
67    /// Register a request from `agent_id` and await its answer. Returns a neutral
68    /// (empty-text) response if the request is cancelled before it is answered.
69    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        // Wake the driver so it ticks and reflects this open request into the
84        // agent's status (Active → Waiting) for the dashboard to surface.
85        self.nudge();
86        // The lock is released before awaiting; answer()/cancel() can run.
87        rx.await
88            .unwrap_or_else(|_| InteractionResponse::text(id, ""))
89    }
90
91    /// Every open request, as `(agent_id, request)` pairs, for surfacing to
92    /// clients.
93    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    /// Answer an open request. Returns `false` if no request with that id is
103    /// open (already answered, cancelled, or never existed).
104    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                // The awaiting `submit` may have gone away (agent despawned); a
113                // failed send is harmless.
114                let _ = entry.responder.send(response);
115                // Wake the driver so it reflects the now-cleared request back
116                // into the agent's status (Waiting → Active).
117                self.nudge();
118                true
119            }
120            None => false,
121        }
122    }
123
124    /// Cancel an open request (its `submit` returns the neutral response).
125    /// Returns `false` if no such request is open.
126    pub fn cancel(&self, request_id: &str) -> bool {
127        // Dropping the entry drops its responder, waking `submit` with an error.
128        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    /// Cancel every open request belonging to `agent_id`, returning how many were
141    /// closed. Each one's `submit` wakes with the neutral response.
142    ///
143    /// This is the per-agent counterpart of [`Self::cancel`], which is keyed by
144    /// request id - an id a canceller of a *run* doesn't have. Without it,
145    /// cancelling a run left its `ask` future blocked forever: the future holds a
146    /// tool-lane worker (the lane has a fixed worker count, so enough of them
147    /// stall every other agent's tool batches), and the orphaned request keeps
148    /// being surfaced by `lev respond` and the dashboard for a run that no longer
149    /// exists.
150    pub fn cancel_for_agent(&self, agent_id: &str) -> usize {
151        // Dropping each entry drops its responder, waking `submit` with an error.
152        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    /// A per-agent [`InteractionBackend`] backed by this hub.
164    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/// A per-agent [`InteractionBackend`] that routes `ask` through an
173/// [`InteractionHub`].
174#[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    /// Let a just-spawned `submit` task reach its await point. `submit` inserts
196    /// into the registry synchronously before awaiting, so a few yields on the
197    /// current-thread test runtime are enough for it to have registered.
198    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        // `pending` holds *every* agent's open prompt, so a panic while holding
207        // it must not poison it: a poisoned registry makes
208        // `pending()`/`answer()`/`cancel()` panic for all agents and the
209        // dashboard (issue #109).
210        let hub = InteractionHub::new();
211        let prev = std::panic::take_hook();
212        std::panic::set_hook(Box::new(|_| {})); // silence the deliberate panic
213        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        // No longer pending.
242        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        // A second attach is ignored - the handle is set once at startup.
257        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        // submit() nudged the original wake.
264        wake.notified().await;
265
266        // answer() nudges it again (consume the submit permit first).
267        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; // drain the submit nudge
282
283        assert!(hub.cancel("q2"));
284        wake.notified().await; // cancel nudged the wake
285        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("")); // neutral
299
300        // Cancelling again ⇒ nothing to cancel.
301        assert!(!hub.cancel("q2"));
302    }
303}