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 its caller until the request is answered or cancelled, which for
12//! a person at a keyboard can be a very long time. When the caller is a tool
13//! batch it waits [`off_lane`](crate::tool_bridge::off_lane), so a prompt nobody
14//! has answered yet costs the tool lane no capacity.
15//!
16//! "A very long time" used to mean "for ever": a run whose operator had walked
17//! away sat in `WaitingInput` until the daemon died, holding its slot the whole
18//! time (issue #204). [`InteractionHub::set_timeout_secs`] puts a deadline on
19//! that wait.
20
21use std::collections::HashMap;
22use std::sync::atomic::{AtomicU64, Ordering};
23use std::sync::{Arc, Mutex, OnceLock, PoisonError};
24use std::time::Duration;
25
26use bevy_ecs::prelude::Resource;
27use leviath_core::interaction::{InteractionRequest, InteractionResponse};
28use tokio::sync::{Notify, oneshot};
29
30use crate::dynamic_interaction::InteractionBackend;
31
32/// One open interaction awaiting an answer.
33struct PendingEntry {
34    /// The agent (by id) that raised the request.
35    agent_id: String,
36    /// The request itself (surfaced to clients).
37    request: InteractionRequest,
38    /// Fulfilled by [`InteractionHub::answer`]; dropped by [`InteractionHub::cancel`].
39    responder: oneshot::Sender<InteractionResponse>,
40}
41
42/// A process-wide registry of open interactions, keyed by request id. Cheap to
43/// clone (shared `Arc`). Also a bevy [`Resource`] so the tick loop's
44/// [`reflect_interaction_status`](crate::pipeline::reflect_interaction_status)
45/// system can mirror open requests into agent status.
46#[derive(Clone, Default, Resource)]
47pub struct InteractionHub {
48    pending: Arc<Mutex<HashMap<String, PendingEntry>>>,
49    /// The tick-loop wake handle, attached once by
50    /// [`PipelineWorld::insert_interaction_hub`](crate::world::PipelineWorld::insert_interaction_hub).
51    /// Opening, answering, or cancelling a request nudges it so the loop ticks
52    /// (while otherwise parked) and reflects the change into agent status.
53    wake: Arc<OnceLock<Arc<Notify>>>,
54    /// How long an open request may go unanswered before the hub resolves it
55    /// itself, in seconds. `0` (the default) waits indefinitely. Set once at
56    /// daemon start from `[limits] interaction_timeout_secs`.
57    timeout_secs: Arc<AtomicU64>,
58}
59
60/// The default deadline on an unanswered prompt, in seconds.
61///
62/// An hour is long enough that a person who is actually there answers well
63/// inside it, and short enough that a run whose operator has gone home releases
64/// its slot the same day rather than holding it until the daemon restarts.
65pub const DEFAULT_INTERACTION_TIMEOUT_SECS: u64 = 3600;
66
67impl InteractionHub {
68    /// A fresh, empty hub.
69    pub fn new() -> Self {
70        Self::default()
71    }
72
73    /// Attach the tick-loop wake handle so registry changes wake the driver.
74    /// Idempotent: a second call is ignored (the handle is set once at startup).
75    pub fn attach_wake(&self, wake: Arc<Notify>) {
76        let _ = self.wake.set(wake);
77    }
78
79    /// Set how long an open request may go unanswered before the hub resolves it
80    /// itself. `0` waits indefinitely - the behaviour before issue #204.
81    ///
82    /// Applies to requests opened from here on; a request already parked keeps
83    /// the deadline it was opened with.
84    pub fn set_timeout_secs(&self, secs: u64) {
85        self.timeout_secs.store(secs, Ordering::Relaxed);
86    }
87
88    /// The current deadline, or `None` when the hub waits indefinitely.
89    fn timeout(&self) -> Option<Duration> {
90        match self.timeout_secs.load(Ordering::Relaxed) {
91            0 => None,
92            secs => Some(Duration::from_secs(secs)),
93        }
94    }
95
96    /// Wake the tick loop if a handle is attached (no-op otherwise).
97    fn nudge(&self) {
98        if let Some(wake) = self.wake.get() {
99            wake.notify_one();
100        }
101    }
102
103    /// Register a request from `agent_id` and await its answer. Returns a neutral
104    /// (empty-text) response if the request is cancelled before it is answered,
105    /// or if it goes unanswered past [`set_timeout_secs`](Self::set_timeout_secs).
106    ///
107    /// The timeout deliberately produces the *same* neutral response a cancel
108    /// does, so nothing downstream has to learn a third outcome: an approval or
109    /// a taint gate reads it as not-approved and denies, an `ask_user_*` tool
110    /// reports that no answer came, and an interaction point proceeds with empty
111    /// user text - each exactly as it already behaves for a cancelled request.
112    async fn submit(&self, agent_id: &str, request: InteractionRequest) -> InteractionResponse {
113        let id = request.id.clone();
114        let (responder, rx) = oneshot::channel();
115        self.pending
116            .lock()
117            .unwrap_or_else(PoisonError::into_inner)
118            .insert(
119                id.clone(),
120                PendingEntry {
121                    agent_id: agent_id.to_string(),
122                    request,
123                    responder,
124                },
125            );
126        // Wake the driver so it ticks and reflects this open request into the
127        // agent's status (Active → Waiting) for the dashboard to surface.
128        self.nudge();
129        // The lock is released before awaiting; answer()/cancel() can run.
130        //
131        // Off the tool lane, because there is no bound on how long a person
132        // takes: a batch that held lane capacity through a prompt was capacity
133        // no other agent's tools could use (issue #191). Callers that are not
134        // tool batches - the gate-prompt and interaction-point lanes - have no
135        // ticket, and for them this is a plain await.
136        let Some(deadline) = self.timeout() else {
137            return crate::tool_bridge::off_lane(rx)
138                .await
139                .unwrap_or_else(|_| InteractionResponse::text(id, ""));
140        };
141        // `&mut rx` rather than `rx`, so the receiver outlives an elapsed
142        // deadline and a reply that landed in that same instant can still be
143        // collected instead of thrown away.
144        let mut rx = rx;
145        match crate::tool_bridge::off_lane(tokio::time::timeout(deadline, &mut rx)).await {
146            Ok(answered) => answered.unwrap_or_else(|_| InteractionResponse::text(id, "")),
147            Err(_elapsed) => self.expire(agent_id, &id, &mut rx),
148        }
149    }
150
151    /// Resolve a request nobody answered in time: drop it from the open set so
152    /// the tick loop takes the agent out of `Waiting`, and hand its caller the
153    /// neutral response.
154    ///
155    /// A real answer that arrived as the deadline passed still wins. It is
156    /// already sitting in the channel, and handing back the neutral response
157    /// instead would throw away what a person actually said.
158    fn expire(
159        &self,
160        agent_id: &str,
161        id: &str,
162        rx: &mut oneshot::Receiver<InteractionResponse>,
163    ) -> InteractionResponse {
164        self.pending
165            .lock()
166            .unwrap_or_else(PoisonError::into_inner)
167            .remove(id);
168        if let Ok(answered) = rx.try_recv() {
169            return answered;
170        }
171        tracing::warn!(
172            agent = %agent_id,
173            request = %id,
174            "no answer within the interaction timeout - resolving it as unanswered"
175        );
176        // Wake the driver so `reflect_interaction_status` moves the agent from
177        // Waiting back to Active now, rather than at the next re-drive.
178        self.nudge();
179        InteractionResponse::text(id, "")
180    }
181
182    /// Every open request, as `(agent_id, request)` pairs, for surfacing to
183    /// clients.
184    pub fn pending(&self) -> Vec<(String, InteractionRequest)> {
185        self.pending
186            .lock()
187            .unwrap_or_else(PoisonError::into_inner)
188            .values()
189            .map(|e| (e.agent_id.clone(), e.request.clone()))
190            .collect()
191    }
192
193    /// Answer an open request. Returns `false` if no request with that id is
194    /// open (already answered, cancelled, or never existed).
195    pub fn answer(&self, response: InteractionResponse) -> bool {
196        let entry = self
197            .pending
198            .lock()
199            .unwrap_or_else(PoisonError::into_inner)
200            .remove(&response.request_id);
201        match entry {
202            Some(entry) => {
203                // The awaiting `submit` may have gone away (agent despawned); a
204                // failed send is harmless.
205                let _ = entry.responder.send(response);
206                // Wake the driver so it reflects the now-cleared request back
207                // into the agent's status (Waiting → Active).
208                self.nudge();
209                true
210            }
211            None => false,
212        }
213    }
214
215    /// Cancel an open request (its `submit` returns the neutral response).
216    /// Returns `false` if no such request is open.
217    pub fn cancel(&self, request_id: &str) -> bool {
218        // Dropping the entry drops its responder, waking `submit` with an error.
219        let removed = self
220            .pending
221            .lock()
222            .unwrap_or_else(PoisonError::into_inner)
223            .remove(request_id)
224            .is_some();
225        if removed {
226            self.nudge();
227        }
228        removed
229    }
230
231    /// Cancel every open request belonging to `agent_id`, returning how many were
232    /// closed. Each one's `submit` wakes with the neutral response.
233    ///
234    /// This is the per-agent counterpart of [`Self::cancel`], which is keyed by
235    /// request id - an id a canceller of a *run* doesn't have. Without it,
236    /// cancelling a run left its `ask` future blocked forever, and the orphaned
237    /// request kept being surfaced by `lev respond` and the dashboard for a run
238    /// that no longer exists.
239    pub fn cancel_for_agent(&self, agent_id: &str) -> usize {
240        // Dropping each entry drops its responder, waking `submit` with an error.
241        let mut pending = self.pending.lock().unwrap_or_else(PoisonError::into_inner);
242        let before = pending.len();
243        pending.retain(|_, entry| entry.agent_id != agent_id);
244        let removed = before - pending.len();
245        drop(pending);
246        if removed > 0 {
247            self.nudge();
248        }
249        removed
250    }
251
252    /// A per-agent [`InteractionBackend`] backed by this hub.
253    pub fn backend_for(&self, agent_id: impl Into<String>) -> HubInteractionBackend {
254        HubInteractionBackend {
255            hub: self.clone(),
256            agent_id: agent_id.into(),
257        }
258    }
259}
260
261/// A per-agent [`InteractionBackend`] that routes `ask` through an
262/// [`InteractionHub`].
263#[derive(Clone)]
264pub struct HubInteractionBackend {
265    hub: InteractionHub,
266    agent_id: String,
267}
268
269#[async_trait::async_trait]
270impl InteractionBackend for HubInteractionBackend {
271    async fn ask(&self, request: InteractionRequest) -> InteractionResponse {
272        self.hub.submit(&self.agent_id, request).await
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    fn req(id: &str) -> InteractionRequest {
281        InteractionRequest::free_text(id, "prompt?", "stage", true)
282    }
283
284    /// Let a just-spawned `submit` task reach its await point. `submit` inserts
285    /// into the registry synchronously before awaiting, so a few yields on the
286    /// current-thread test runtime are enough for it to have registered.
287    async fn settle() {
288        for _ in 0..8 {
289            tokio::task::yield_now().await;
290        }
291    }
292
293    #[test]
294    fn a_poisoned_registry_still_serves_every_other_agent() {
295        // `pending` holds *every* agent's open prompt, so a panic while holding
296        // it must not poison it: a poisoned registry makes
297        // `pending()`/`answer()`/`cancel()` panic for all agents and the
298        // dashboard (issue #109).
299        let hub = InteractionHub::new();
300        let prev = std::panic::take_hook();
301        std::panic::set_hook(Box::new(|_| {})); // silence the deliberate panic
302        let poisoned = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
303            let _guard = hub.pending.lock().expect("fresh lock");
304            panic!("a panic while holding the interaction registry");
305        }));
306        std::panic::set_hook(prev);
307        assert!(poisoned.is_err());
308        assert!(hub.pending.is_poisoned(), "the lock really is poisoned");
309
310        assert!(hub.pending().is_empty());
311        assert!(!hub.cancel("nope"));
312        assert!(!hub.answer(InteractionResponse::text("nope", "x")));
313    }
314
315    #[tokio::test]
316    async fn ask_is_answered_through_the_hub() {
317        let hub = InteractionHub::new();
318        let backend = hub.backend_for("agent-a");
319        let asking = tokio::spawn(async move { backend.ask(req("q1")).await });
320
321        settle().await;
322        let pending = hub.pending();
323        assert_eq!(pending.len(), 1);
324        assert_eq!(pending[0].0, "agent-a");
325        assert_eq!(pending[0].1.id, "q1");
326
327        assert!(hub.answer(InteractionResponse::text("q1", "hello")));
328        let response = asking.await.unwrap();
329        assert_eq!(response.value.as_deref(), Some("hello"));
330        // No longer pending.
331        assert!(hub.pending().is_empty());
332    }
333
334    /// An unanswered prompt must not hold tool-lane capacity.
335    ///
336    /// The answer can arrive from another agent's tool call, and on a lane with
337    /// no room left that call is queued behind the batch that is waiting for it.
338    /// That is the shape that froze whole factories in issue #191: everything
339    /// looked `waiting`, nothing was failed, and nothing ever moved again.
340    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
341    async fn a_batch_waiting_on_a_prompt_does_not_hold_the_tool_lane() {
342        use crate::tool_bridge::{ToolJob, ToolLane, ToolLaneStats};
343        use bevy_ecs::entity::Entity;
344
345        let hub = InteractionHub::new();
346        let (job_tx, job_rx) = tokio::sync::mpsc::unbounded_channel();
347        let (result_tx, mut results) = tokio::sync::mpsc::unbounded_channel();
348        let stats = Arc::new(ToolLaneStats::new(1));
349        let lane = ToolLane::new(
350            tokio::runtime::Handle::current(),
351            result_tx,
352            Arc::new(Notify::new()),
353            1,
354            stats.clone(),
355        );
356        let serving = lane.serve(job_rx);
357        let submit = |entity: u32, exec: crate::tool_bridge::BoxedToolExec| {
358            stats.enqueued();
359            job_tx
360                .send(ToolJob {
361                    entity: Entity::from_raw_u32(entity).expect("a small index is a valid id"),
362                    exec,
363                    cancel: crate::cancel::CancelToken::new(),
364                })
365                .expect("the lane is serving");
366        };
367
368        // The whole lane, spent on waiting for an answer.
369        let asking = hub.backend_for("agent-a");
370        submit(
371            1,
372            Box::new(move || {
373                Box::pin(async move {
374                    let response = asking.ask(req("q1")).await;
375                    vec![("q1".to_string(), response.value.unwrap_or_default())]
376                })
377            }),
378        );
379        wait_for_prompt(&hub).await;
380        assert_eq!(stats.parked(), 1, "the asker stepped off the lane");
381
382        // The answer, as another batch - only reachable if the lane is free.
383        let answering = hub.clone();
384        submit(
385            2,
386            Box::new(move || {
387                Box::pin(async move {
388                    answering.answer(InteractionResponse::text("q1", "hello"));
389                    vec![("answered".to_string(), "ok".to_string())]
390                })
391            }),
392        );
393
394        let mut answers = Vec::new();
395        for _ in 0..2 {
396            let outcome = tokio::time::timeout(std::time::Duration::from_secs(30), results.recv())
397                .await
398                .expect("both batches finished")
399                .expect("an outcome arrived");
400            answers.extend(outcome.results);
401        }
402        answers.sort();
403        assert_eq!(
404            answers,
405            vec![
406                ("answered".to_string(), "ok".to_string()),
407                ("q1".to_string(), "hello".to_string()),
408            ],
409            "the asker got its answer from the batch behind it"
410        );
411
412        drop(job_tx);
413        tokio::time::timeout(std::time::Duration::from_secs(30), serving)
414            .await
415            .expect("the lane drained")
416            .expect("the lane task ended");
417    }
418
419    /// Block until a prompt is registered. `submit` inserts synchronously before
420    /// awaiting, but on a multi-threaded runtime the batch task may not have been
421    /// polled yet, so yielding a fixed number of times is not enough.
422    async fn wait_for_prompt(hub: &InteractionHub) {
423        tokio::time::timeout(std::time::Duration::from_secs(30), async {
424            while hub.pending().is_empty() {
425                tokio::time::sleep(std::time::Duration::from_millis(5)).await;
426            }
427        })
428        .await
429        .expect("the prompt was raised");
430    }
431
432    #[tokio::test]
433    async fn answer_unknown_request_is_false() {
434        let hub = InteractionHub::new();
435        assert!(!hub.answer(InteractionResponse::text("nope", "x")));
436    }
437
438    #[tokio::test]
439    async fn submit_and_answer_nudge_the_attached_wake() {
440        let hub = InteractionHub::new();
441        let wake = Arc::new(Notify::new());
442        hub.attach_wake(wake.clone());
443        // A second attach is ignored - the handle is set once at startup.
444        hub.attach_wake(Arc::new(Notify::new()));
445
446        let backend = hub.backend_for("agent-a");
447        let asking = tokio::spawn(async move { backend.ask(req("q1")).await });
448        settle().await;
449
450        // submit() nudged the original wake.
451        wake.notified().await;
452
453        // answer() nudges it again (consume the submit permit first).
454        assert!(hub.answer(InteractionResponse::text("q1", "hi")));
455        wake.notified().await;
456        assert_eq!(asking.await.unwrap().value.as_deref(), Some("hi"));
457    }
458
459    #[tokio::test]
460    async fn cancel_nudges_the_attached_wake() {
461        let hub = InteractionHub::new();
462        let wake = Arc::new(Notify::new());
463        hub.attach_wake(wake.clone());
464
465        let backend = hub.backend_for("agent-a");
466        let asking = tokio::spawn(async move { backend.ask(req("q2")).await });
467        settle().await;
468        wake.notified().await; // drain the submit nudge
469
470        assert!(hub.cancel("q2"));
471        wake.notified().await; // cancel nudged the wake
472        let _ = asking.await.unwrap();
473    }
474
475    #[tokio::test]
476    async fn cancel_wakes_submit_with_neutral_response() {
477        let hub = InteractionHub::new();
478        let backend = hub.backend_for("agent-a");
479        let asking = tokio::spawn(async move { backend.ask(req("q2")).await });
480
481        settle().await;
482        assert!(hub.cancel("q2"));
483        let response = asking.await.unwrap();
484        assert_eq!(response.request_id, "q2");
485        assert_eq!(response.value.as_deref(), Some("")); // neutral
486
487        // Cancelling again ⇒ nothing to cancel.
488        assert!(!hub.cancel("q2"));
489    }
490
491    // ─── the deadline on an unanswered prompt (issue #204) ───────────────────
492
493    #[tokio::test(start_paused = true)]
494    async fn a_prompt_nobody_answers_is_released_when_the_deadline_passes() {
495        // The zombie in issue #204: six runs sat in `WaitingInput` for hours
496        // because nothing ever aged the request out. Now the hub resolves it
497        // itself and the agent goes back to work.
498        let hub = InteractionHub::new();
499        hub.set_timeout_secs(60);
500        let backend = hub.backend_for("agent-a");
501        let asking = tokio::spawn(async move { backend.ask(req("q1")).await });
502
503        settle().await;
504        assert_eq!(hub.pending().len(), 1, "the prompt is open while it waits");
505
506        // The paused clock jumps to the deadline once nothing else can run.
507        let response = asking.await.unwrap();
508        assert_eq!(response.request_id, "q1");
509        // The same neutral answer a cancel produces: not approved, no text.
510        assert_eq!(response.value.as_deref(), Some(""));
511        assert_eq!(response.approved, None);
512        assert!(
513            hub.pending().is_empty(),
514            "the expired request is off the open list, so the agent leaves Waiting"
515        );
516    }
517
518    #[tokio::test(start_paused = true)]
519    async fn a_deadline_changes_nothing_for_a_prompt_that_is_answered() {
520        // Setting a deadline must not alter the ordinary paths. Both of them run
521        // here: one prompt answered by a person, one cancelled under it.
522        let hub = InteractionHub::new();
523        hub.set_timeout_secs(3600);
524
525        let answered_backend = hub.backend_for("agent-a");
526        let answered = tokio::spawn(async move { answered_backend.ask(req("q1")).await });
527        let cancelled_backend = hub.backend_for("agent-b");
528        let cancelled = tokio::spawn(async move { cancelled_backend.ask(req("q2")).await });
529        settle().await;
530
531        assert!(hub.answer(InteractionResponse::text("q1", "yes, go on")));
532        assert_eq!(answered.await.unwrap().value.as_deref(), Some("yes, go on"));
533
534        assert!(hub.cancel("q2"));
535        assert_eq!(cancelled.await.unwrap().value.as_deref(), Some(""));
536    }
537
538    #[tokio::test(start_paused = true)]
539    async fn a_zero_deadline_waits_for_a_person_however_long_it_takes() {
540        // `0` is the explicit "I will be here" setting, and it has to keep the
541        // old behaviour exactly: the prompt stays open until answered.
542        let hub = InteractionHub::new();
543        hub.set_timeout_secs(0);
544        let backend = hub.backend_for("agent-a");
545        let asking = tokio::spawn(async move { backend.ask(req("q1")).await });
546
547        settle().await;
548        tokio::time::advance(Duration::from_secs(86_400)).await;
549        assert_eq!(hub.pending().len(), 1, "a day later, still waiting");
550
551        assert!(hub.answer(InteractionResponse::text("q1", "here I am")));
552        assert_eq!(asking.await.unwrap().value.as_deref(), Some("here I am"));
553    }
554
555    #[tokio::test(start_paused = true)]
556    async fn the_deadline_denies_rather_than_approves() {
557        // A timeout must never be read as consent: an approval prompt and a
558        // taint gate both go through `response_approved`, which reads the
559        // neutral response as "no".
560        let hub = InteractionHub::new();
561        hub.set_timeout_secs(30);
562        let backend = hub.backend_for("agent-a");
563        let asking = tokio::spawn(async move {
564            backend
565                .ask(InteractionRequest::tool_approval(
566                    "t1",
567                    "shell",
568                    serde_json::json!({"command": "rm -rf /"}),
569                    "implement",
570                ))
571                .await
572        });
573
574        let response = asking.await.unwrap();
575        assert!(!leviath_core::interaction::response_approved(&response));
576    }
577
578    #[tokio::test]
579    async fn an_answer_that_lands_as_the_deadline_passes_still_wins() {
580        // The race: `answer` took the entry out of the registry and sent its
581        // response an instant before the timer fired. Handing back the neutral
582        // response here would throw away what a person actually said.
583        let hub = InteractionHub::new();
584        let (responder, mut rx) = oneshot::channel();
585        responder
586            .send(InteractionResponse::text("q1", "approved by hand"))
587            .expect("the receiver is still alive");
588
589        let response = hub.expire("agent-a", "q1", &mut rx);
590        assert_eq!(response.value.as_deref(), Some("approved by hand"));
591    }
592}