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)]
277#[cfg(test)]
278#[path = "interaction_hub_tests.rs"]
279mod tests;
280
281/// Where a prompt's answer goes once a person gives one.
282///
283/// Both prompt paths - the taint gate and blueprint interaction points - are the
284/// same three things: the hub that owns the conversation, the channel the
285/// resolution is reported on, and the driver to wake once it is. Only the
286/// outcome type differs, so this is generic over it rather than written twice.
287pub struct PromptLane<T> {
288 /// The hub that owns the conversation with the user.
289 pub hub: InteractionHub,
290 /// The channel the resolution is reported on.
291 pub outcomes: tokio::sync::mpsc::UnboundedSender<T>,
292 /// The driver to wake once it is.
293 pub wake: std::sync::Arc<tokio::sync::Notify>,
294}