Skip to main content

netget/llm/
agent_queue.rs

1//! Agent-answered LLM backend queue.
2//!
3//! When NetGet runs as an MCP server in `--llm-agent` mode, protocol servers do
4//! not call any model. Instead every LLM request is enqueued here and answered by
5//! the calling MCP agent (e.g. Claude Code) via the `get_next_llm_request` /
6//! `answer_llm_request` tools. This mirrors the oneshot-rendezvous pattern used by
7//! the FIDO2 `ApprovalManager` (`src/server/usb/fido2/approval.rs`) and
8//! `WebApprovalRequest` (`src/state/app_state.rs`): the producer submits a request
9//! plus a `oneshot::Sender` and awaits the receiver; the consumer resolves it from
10//! outside.
11//!
12//! Two notification paths are offered and the agent picks whichever fits:
13//!   - a long-poll: `get_next_llm_request { wait_seconds }` blocks on [`Notify`],
14//!   - a best-effort FIFO push: on enqueue the new request id is written to the
15//!     configured named pipe so an idle agent can block-read it and get woken.
16
17use crate::utils::clock::{SystemTime, UNIX_EPOCH};
18use std::collections::{HashMap, VecDeque};
19use std::path::PathBuf;
20use std::sync::Mutex;
21use std::time::Duration;
22
23use anyhow::{anyhow, Result};
24use serde_json::Value;
25use tokio::sync::{oneshot, Notify};
26use tracing::debug;
27
28use crate::llm::ollama_client::Message;
29
30/// A queued LLM request awaiting an answer from the calling agent.
31#[derive(Clone, Debug)]
32pub struct PendingLlmRequest {
33    /// Monotonic id assigned on submission (arrival order).
34    pub id: u64,
35    /// Unix time in milliseconds when the request was enqueued.
36    pub created_unix_ms: u64,
37    /// Model name recorded on the request (a placeholder like "agent" in this mode).
38    pub model: String,
39    /// The full prompt: system + user messages the model would have received.
40    pub messages: Vec<Message>,
41    /// Available actions as tool schemas (OpenAI function-tool format), if any.
42    pub tools: Vec<Value>,
43    /// Whether a consumer has already claimed (fetched) this request.
44    pub claimed: bool,
45}
46
47/// One in-flight entry: the request metadata plus the channel that unblocks the
48/// waiting protocol server when the agent answers.
49struct Entry {
50    request: PendingLlmRequest,
51    responder: oneshot::Sender<Vec<Value>>,
52}
53
54struct Inner {
55    /// Ids in arrival order (pending and claimed-but-unanswered).
56    order: VecDeque<u64>,
57    entries: HashMap<u64, Entry>,
58    next_id: u64,
59}
60
61/// A queue of pending LLM requests answered out-of-band by the MCP agent.
62///
63/// Every `inner` lock is taken with `unwrap_or_else(|e| e.into_inner())` rather than
64/// `unwrap()`. The queue is process-wide, so a single panic under the lock would poison it
65/// and make every later `submit`/`claim_next`/`answer` panic in turn — one transient fault
66/// becoming a permanent outage. The guarded state is plain collections that stay coherent.
67pub struct LlmRequestQueue {
68    inner: Mutex<Inner>,
69    notify: Notify,
70    pipe_path: Option<PathBuf>,
71}
72
73impl LlmRequestQueue {
74    /// Create an empty queue. `pipe_path`, if set, receives a `<id>\n` line on each
75    /// enqueue (best-effort push notification).
76    pub fn new(pipe_path: Option<PathBuf>) -> Self {
77        Self {
78            inner: Mutex::new(Inner {
79                order: VecDeque::new(),
80                entries: HashMap::new(),
81                next_id: 1,
82            }),
83            notify: Notify::new(),
84            pipe_path,
85        }
86    }
87
88    /// Configured FIFO path, if any.
89    pub fn pipe_path(&self) -> Option<&PathBuf> {
90        self.pipe_path.as_ref()
91    }
92
93    /// Enqueue a request and return its id plus a receiver the caller awaits for the
94    /// agent's answer (a NetGet action JSON array).
95    pub fn submit(
96        &self,
97        model: String,
98        messages: Vec<Message>,
99        tools: Vec<Value>,
100    ) -> (u64, oneshot::Receiver<Vec<Value>>) {
101        let (tx, rx) = oneshot::channel();
102        let created_unix_ms = SystemTime::now()
103            .duration_since(UNIX_EPOCH)
104            .map(|d| d.as_millis() as u64)
105            .unwrap_or(0);
106
107        let id = {
108            let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
109            let id = inner.next_id;
110            inner.next_id += 1;
111            let request = PendingLlmRequest {
112                id,
113                created_unix_ms,
114                model,
115                messages,
116                tools,
117                claimed: false,
118            };
119            inner.entries.insert(
120                id,
121                Entry {
122                    request,
123                    responder: tx,
124                },
125            );
126            inner.order.push_back(id);
127            id
128        };
129
130        self.write_pipe_notification(id);
131        self.notify.notify_waiters();
132        (id, rx)
133    }
134
135    /// Claim the oldest not-yet-claimed request, marking it claimed. Returns `None`
136    /// if every pending request has already been claimed.
137    pub fn claim_next(&self) -> Option<PendingLlmRequest> {
138        let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
139
140        let mut target = None;
141        for &id in &inner.order {
142            if inner.entries.get(&id).map_or(false, |e| !e.request.claimed) {
143                target = Some(id);
144                break;
145            }
146        }
147        let id = target?;
148        let entry = inner.entries.get_mut(&id)?;
149        entry.request.claimed = true;
150        Some(entry.request.clone())
151    }
152
153    /// Long-poll: claim the next request, or wait up to `timeout` for one to arrive.
154    /// Returns `None` on timeout. If the RPC that called this is cancelled, the
155    /// future is simply dropped (no side effects).
156    pub async fn wait_and_claim(&self, timeout: Duration) -> Option<PendingLlmRequest> {
157        if let Some(req) = self.claim_next() {
158            return Some(req);
159        }
160        let deadline = tokio::time::Instant::now() + timeout;
161        loop {
162            // Register for notification BEFORE re-checking to avoid a lost wakeup.
163            let notified = self.notify.notified();
164            if let Some(req) = self.claim_next() {
165                return Some(req);
166            }
167            tokio::select! {
168                _ = notified => {
169                    if let Some(req) = self.claim_next() {
170                        return Some(req);
171                    }
172                }
173                _ = tokio::time::sleep_until(deadline) => {
174                    return None;
175                }
176            }
177        }
178    }
179
180    /// Resolve a request with the agent's answer, unblocking the waiting server.
181    /// Errors if the id is unknown (already answered, expired, or never existed) or
182    /// if the waiter has already gone away (timed out / connection closed).
183    pub fn answer(&self, id: u64, actions: Vec<Value>) -> Result<()> {
184        let entry = {
185            let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
186            let entry = inner.entries.remove(&id).ok_or_else(|| {
187                anyhow!(
188                    "no pending LLM request with id {} (already answered, expired, or never existed)",
189                    id
190                )
191            })?;
192            inner.order.retain(|&x| x != id);
193            entry
194        };
195
196        entry.responder.send(actions).map_err(|_| {
197            anyhow!(
198                "LLM request {} is no longer waiting (its connection timed out or closed)",
199                id
200            )
201        })?;
202        debug!("agent-queue: request #{} answered", id);
203        Ok(())
204    }
205
206    /// Drop a timed-out / abandoned request so it can no longer be answered.
207    pub fn expire(&self, id: u64) {
208        let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
209        inner.entries.remove(&id);
210        inner.order.retain(|&x| x != id);
211    }
212
213    /// Snapshot of all outstanding requests (pending + claimed-unanswered).
214    pub fn list(&self) -> Vec<PendingLlmRequest> {
215        let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
216        inner
217            .order
218            .iter()
219            .filter_map(|id| inner.entries.get(id).map(|e| e.request.clone()))
220            .collect()
221    }
222
223    /// Best-effort push: write `<id>\n` to the FIFO with a non-blocking open. If no
224    /// reader is attached the open fails with ENXIO — that is expected and ignored;
225    /// the long-poll tool remains the reliable path.
226    fn write_pipe_notification(&self, id: u64) {
227        let Some(path) = self.pipe_path.as_ref() else {
228            return;
229        };
230        #[cfg(unix)]
231        {
232            use std::io::Write;
233            use std::os::unix::fs::{FileTypeExt, OpenOptionsExt};
234            match std::fs::OpenOptions::new()
235                .write(true)
236                .custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW)
237                .open(path)
238            {
239                Ok(mut f) => {
240                    // Startup validates the path, but it may have been replaced since.
241                    // Check the opened descriptor before writing, so even a raced rename
242                    // cannot turn a best-effort FIFO notification into a file overwrite.
243                    if !f
244                        .metadata()
245                        .is_ok_and(|metadata| metadata.file_type().is_fifo())
246                    {
247                        debug!(
248                            "agent-queue: notification path is not a FIFO; skipping #{}",
249                            id
250                        );
251                        return;
252                    }
253                    if let Err(e) = writeln!(f, "{}", id) {
254                        debug!("agent-queue: FIFO write for #{} failed: {}", id, e);
255                    }
256                }
257                Err(e) => {
258                    debug!(
259                        "agent-queue: FIFO not ready for #{} ({}): no reader attached",
260                        id, e
261                    );
262                }
263            }
264        }
265        #[cfg(not(unix))]
266        {
267            let _ = (path, id);
268        }
269    }
270}