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}