1pub mod actions;
43
44pub use actions::StompClientProtocol;
45
46use anyhow::{anyhow, bail, Context, Result};
47use std::net::SocketAddr;
48use std::sync::Arc;
49use std::time::Duration;
50use tokio::io::{AsyncReadExt, AsyncWriteExt};
51use tokio::net::TcpStream;
52use tokio::sync::{mpsc, Mutex};
53use tracing::{debug, error, info, trace, warn};
54
55use crate::client::llm_budget::call_llm_for_client;
56use crate::client::stomp::actions::{
57 DISCONNECT_RECEIPT_ID, STOMP_ACCEPT_VERSION, STOMP_CLIENT_CONNECTED_EVENT,
58 STOMP_CLIENT_ERROR_RECEIVED_EVENT, STOMP_CLIENT_MESSAGE_RECEIVED_EVENT,
59 STOMP_CLIENT_RECEIPT_RECEIVED_EVENT, STOMP_HEARTBEAT,
60};
61use crate::llm::actions::client_trait::Client;
62use crate::llm::ollama_client::OllamaClient;
63use crate::protocol::{Event, StartupParams};
64use crate::server::stomp::frame::{self, ParseOutcome, StompFrame};
65use crate::state::app_state::AppState;
66use crate::state::{ClientId, ClientStatus};
67
68const DEFAULT_HANDSHAKE_TIMEOUT_SECS: u64 = 20;
70
71const DISCONNECT_GRACE: Duration = Duration::from_secs(10);
77
78pub struct StompClient;
80
81enum Flow {
83 Continue,
85 DisconnectSent,
87 Close,
89}
90
91impl StompClient {
92 pub async fn connect_with_llm_actions(
96 remote_addr: String,
97 llm_client: OllamaClient,
98 app_state: Arc<AppState>,
99 status_tx: mpsc::UnboundedSender<String>,
100 client_id: ClientId,
101 startup_params: Option<StartupParams>,
102 ) -> Result<SocketAddr> {
103 let (host_param, login, passcode, use_stomp_command, handshake_timeout_secs) =
106 match &startup_params {
107 Some(params) => (
108 params.get_optional_string("host")?,
109 params.get_optional_string("login")?,
110 params.get_optional_string("passcode")?,
111 params
112 .get_optional_bool("use_stomp_command")?
113 .unwrap_or(false),
114 params
115 .get_optional_u64("handshake_timeout_secs")?
116 .unwrap_or(DEFAULT_HANDSHAKE_TIMEOUT_SECS),
117 ),
118 None => (None, None, None, false, DEFAULT_HANDSHAKE_TIMEOUT_SECS),
119 };
120
121 let stream = TcpStream::connect(&remote_addr)
122 .await
123 .with_context(|| format!("Failed to connect to STOMP broker at {remote_addr}"))?;
124 let local_addr = stream.local_addr()?;
125 let peer_addr = stream.peer_addr()?;
126
127 let (mut read_half, write_half) = tokio::io::split(stream);
128 let write_half = Arc::new(Mutex::new(write_half));
129
130 let host = host_param.unwrap_or_else(|| host_of(&remote_addr));
134
135 for (field, value) in [
143 ("host", Some(host.as_str())),
144 ("login", login.as_deref()),
145 ("passcode", passcode.as_deref()),
146 ] {
147 if let Some(value) = value {
148 if !crate::server::stomp::frame::is_safe_unescaped_header(value) {
149 anyhow::bail!(
150 "STOMP startup parameter '{field}' may not contain a newline, carriage \
151 return, colon or NUL: CONNECT headers are exempt from 1.2 escaping, so \
152 such a value would forge a header rather than appear in this one"
153 );
154 }
155 }
156 }
157
158 let connect_frame = build_connect_frame(
159 use_stomp_command,
160 &host,
161 login.as_deref(),
162 passcode.as_deref(),
163 );
164 {
165 let mut guard = write_half.lock().await;
166 guard
167 .write_all(&connect_frame)
168 .await
169 .context("Failed to write the STOMP CONNECT frame")?;
170 guard.flush().await.context("Failed to flush CONNECT")?;
171 }
172 debug!(
173 "STOMP client {} sent {} to {} (host={}, accept-version={}, heart-beat={})",
174 client_id,
175 if use_stomp_command {
176 "STOMP"
177 } else {
178 "CONNECT"
179 },
180 peer_addr,
181 host,
182 STOMP_ACCEPT_VERSION,
183 STOMP_HEARTBEAT
184 );
185
186 let mut pending: Vec<u8> = Vec::new();
190 let reply = read_one_frame(
191 &mut read_half,
192 &mut pending,
193 Duration::from_secs(handshake_timeout_secs),
194 )
195 .await
196 .context("STOMP handshake failed")?;
197
198 let (version, session, server) = validate_connected(&reply)?;
199
200 info!(
201 "STOMP client {} connected to {} (version={}, session={}, server={})",
202 client_id,
203 peer_addr,
204 version,
205 if session.is_empty() {
206 "-"
207 } else {
208 session.as_str()
209 },
210 if server.is_empty() {
211 "-"
212 } else {
213 server.as_str()
214 }
215 );
216 app_state
217 .update_client_status(client_id, ClientStatus::Connected)
218 .await;
219 let _ = status_tx.send(format!(
220 "[CLIENT] STOMP client {client_id} connected to {peer_addr} (session {})",
221 if session.is_empty() {
222 "-"
223 } else {
224 session.as_str()
225 }
226 ));
227 let _ = status_tx.send("__UPDATE_UI__".to_string());
228
229 let mut command_rx =
233 crate::client::command_support::register_command_channel(&app_state, client_id).await;
234
235 let protocol = StompClientProtocol::new();
236 let memory = Arc::new(Mutex::new(
237 app_state
238 .get_memory_for_client(client_id)
239 .await
240 .unwrap_or_default(),
241 ));
242
243 let mut disconnect_deadline: Option<tokio::time::Instant> = None;
244
245 let connected_event = Event::new(
246 &STOMP_CLIENT_CONNECTED_EVENT,
247 serde_json::json!({
248 "version": version,
249 "session": session,
250 "server": server,
251 }),
252 );
253 match report_event(
254 connected_event,
255 &protocol,
256 &write_half,
257 &llm_client,
258 &app_state,
259 &status_tx,
260 client_id,
261 &memory,
262 )
263 .await
264 {
265 Flow::Close => {
266 let _ = write_half.lock().await.shutdown().await;
269 app_state.remove_client_handle(client_id).await;
270 app_state
271 .update_client_status(client_id, ClientStatus::Disconnected)
272 .await;
273 let _ = status_tx.send("__UPDATE_UI__".to_string());
274 return Ok(local_addr);
275 }
276 Flow::DisconnectSent => {
277 disconnect_deadline = Some(tokio::time::Instant::now() + DISCONNECT_GRACE);
278 }
279 Flow::Continue => {}
280 }
281
282 let task_registrar = app_state.clone();
286 let task_handle = tokio::spawn(async move {
287 run_session(
288 read_half,
289 write_half,
290 pending,
291 &mut command_rx,
292 disconnect_deadline,
293 protocol,
294 llm_client,
295 app_state.clone(),
296 status_tx.clone(),
297 client_id,
298 memory,
299 )
300 .await;
301
302 app_state.remove_client_handle(client_id).await;
305 let _ = status_tx.send("__UPDATE_UI__".to_string());
306 });
307 task_registrar
308 .register_client_task(client_id, task_handle)
309 .await;
310
311 Ok(local_addr)
312 }
313}
314
315#[allow(clippy::too_many_arguments)]
317async fn run_session(
318 mut read_half: tokio::io::ReadHalf<TcpStream>,
319 write_half: Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>,
320 mut pending: Vec<u8>,
321 command_rx: &mut mpsc::Receiver<crate::state::client_handles::ClientCommand>,
322 mut disconnect_deadline: Option<tokio::time::Instant>,
323 protocol: StompClientProtocol,
324 llm_client: OllamaClient,
325 app_state: Arc<AppState>,
326 status_tx: mpsc::UnboundedSender<String>,
327 client_id: ClientId,
328 memory: Arc<Mutex<String>>,
329) {
330 let mut read_buf = vec![0u8; 8192];
331
332 'session: loop {
333 loop {
337 let outcome = match frame::parse_frame(&pending) {
338 Ok(outcome) => outcome,
339 Err(e) => {
340 error!("STOMP client {client_id} could not parse the broker's frame: {e}");
343 let _ = status_tx.send(format!(
344 "[CLIENT] ✖ STOMP client {client_id} framing error: {e}"
345 ));
346 break 'session;
347 }
348 };
349 match outcome {
350 ParseOutcome::Incomplete => break,
351 ParseOutcome::Heartbeat { consumed } => {
352 trace!("STOMP client {client_id} drained {consumed} inter-frame EOL byte(s)");
353 pending.drain(..consumed);
354 }
355 ParseOutcome::Frame { frame, consumed } => {
356 pending.drain(..consumed);
357 match handle_frame(
358 frame,
359 &protocol,
360 &write_half,
361 &llm_client,
362 &app_state,
363 &status_tx,
364 client_id,
365 &memory,
366 )
367 .await
368 {
369 Flow::Continue => {}
370 Flow::DisconnectSent => {
371 disconnect_deadline =
372 Some(tokio::time::Instant::now() + DISCONNECT_GRACE);
373 }
374 Flow::Close => break 'session,
375 }
376 }
377 }
378 }
379
380 let grace = async {
381 match disconnect_deadline {
382 Some(deadline) => tokio::time::sleep_until(deadline).await,
383 None => std::future::pending::<()>().await,
384 }
385 };
386
387 tokio::select! {
388 read = read_half.read(&mut read_buf) => match read {
391 Ok(0) => {
392 info!("STOMP client {client_id} — broker closed the connection");
393 break 'session;
394 }
395 Ok(n) => {
396 trace!("STOMP client {client_id} read {n} byte(s)");
397 pending.extend_from_slice(&read_buf[..n]);
398 }
399 Err(e) => {
400 error!("STOMP client {client_id} read error: {e}");
401 app_state
402 .update_client_status(client_id, ClientStatus::Error(e.to_string()))
403 .await;
404 let _ = status_tx.send("__UPDATE_UI__".to_string());
405 return;
406 }
407 },
408
409 Some(command) = command_rx.recv() => {
410 let is_disconnect = command
415 .action
416 .get("type")
417 .and_then(|v| v.as_str())
418 == Some("disconnect");
419
420 let should_break = crate::client::command_support::handle_stream_client_command(
421 &protocol,
422 &write_half,
423 command,
424 client_id,
425 &app_state,
426 &status_tx,
427 )
428 .await;
429
430 if is_disconnect {
431 disconnect_deadline = Some(tokio::time::Instant::now() + DISCONNECT_GRACE);
432 }
433 if should_break {
434 break 'session;
435 }
436 }
437
438 _ = grace => {
439 warn!(
440 "STOMP client {client_id} closing without the RECEIPT for its DISCONNECT \
441 ({}s grace elapsed) — the broker acknowledged nothing",
442 DISCONNECT_GRACE.as_secs()
443 );
444 break 'session;
445 }
446 }
447 }
448
449 let _ = write_half.lock().await.shutdown().await;
450 app_state
451 .update_client_status(client_id, ClientStatus::Disconnected)
452 .await;
453 let _ = status_tx.send(format!("[CLIENT] STOMP client {client_id} disconnected"));
454}
455
456#[allow(clippy::too_many_arguments)]
458async fn handle_frame(
459 frame: StompFrame,
460 protocol: &StompClientProtocol,
461 write_half: &Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>,
462 llm_client: &OllamaClient,
463 app_state: &Arc<AppState>,
464 status_tx: &mpsc::UnboundedSender<String>,
465 client_id: ClientId,
466 memory: &Arc<Mutex<String>>,
467) -> Flow {
468 match frame.command.as_str() {
469 "MESSAGE" => {
470 let (body, body_encoding) = body_for_event(&frame.body);
471 let event = Event::new(
472 &STOMP_CLIENT_MESSAGE_RECEIVED_EVENT,
473 serde_json::json!({
474 "destination": frame.header("destination").unwrap_or_default(),
475 "message_id": frame.header("message-id").unwrap_or_default(),
476 "subscription": frame.header("subscription").unwrap_or_default(),
477 "headers": frame.headers_except(&["destination", "message-id", "subscription"]),
478 "body": body,
479 "body_encoding": body_encoding,
480 }),
481 );
482 debug!(
483 "STOMP client {client_id} received MESSAGE on {} (subscription {})",
484 frame.header("destination").unwrap_or("-"),
485 frame.header("subscription").unwrap_or("-")
486 );
487 report_event(
488 event, protocol, write_half, llm_client, app_state, status_tx, client_id, memory,
489 )
490 .await
491 }
492
493 "RECEIPT" => {
494 let receipt_id = frame.header("receipt-id").unwrap_or_default().to_string();
495
496 if receipt_id == DISCONNECT_RECEIPT_ID {
500 info!(
501 "STOMP client {client_id} — broker acknowledged DISCONNECT, closing \
502 gracefully"
503 );
504 return Flow::Close;
505 }
506
507 debug!("STOMP client {client_id} received RECEIPT {receipt_id}");
508 let event = Event::new(
509 &STOMP_CLIENT_RECEIPT_RECEIVED_EVENT,
510 serde_json::json!({ "receipt_id": receipt_id }),
511 );
512 report_event(
513 event, protocol, write_half, llm_client, app_state, status_tx, client_id, memory,
514 )
515 .await
516 }
517
518 "ERROR" => {
519 let message = frame.header("message").unwrap_or_default().to_string();
520 let (body, body_encoding) = body_for_event(&frame.body);
521 warn!("STOMP client {client_id} received ERROR from the broker: {message}");
522 let _ = status_tx.send(format!(
523 "[CLIENT] ⚠ STOMP client {client_id} got an ERROR frame: {message}"
524 ));
525 let event = Event::new(
526 &STOMP_CLIENT_ERROR_RECEIVED_EVENT,
527 serde_json::json!({
528 "message": message,
529 "body": body,
530 "body_encoding": body_encoding,
531 }),
532 );
533 let _ = report_event(
537 event, protocol, write_half, llm_client, app_state, status_tx, client_id, memory,
538 )
539 .await;
540 Flow::Close
541 }
542
543 "CONNECTED" => {
544 warn!("STOMP client {client_id} ignoring an unexpected second CONNECTED frame");
547 Flow::Continue
548 }
549
550 other => {
551 warn!(
552 "STOMP client {client_id} ignoring a frame with unexpected command {other:?} — \
553 a broker sends only MESSAGE, RECEIPT, ERROR and CONNECTED"
554 );
555 Flow::Continue
556 }
557 }
558}
559
560#[allow(clippy::too_many_arguments)]
562async fn report_event(
563 event: Event,
564 protocol: &StompClientProtocol,
565 write_half: &Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>,
566 llm_client: &OllamaClient,
567 app_state: &Arc<AppState>,
568 status_tx: &mpsc::UnboundedSender<String>,
569 client_id: ClientId,
570 memory: &Arc<Mutex<String>>,
571) -> Flow {
572 let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
573 debug!(
574 "STOMP client {client_id} has no instruction; {} is not reported to the model",
575 event.event_type.id
576 );
577 return Flow::Continue;
578 };
579
580 let memory_snapshot = memory.lock().await.clone();
583
584 match call_llm_for_client(
585 llm_client,
586 app_state,
587 client_id.to_string(),
588 &instruction,
589 &memory_snapshot,
590 Some(&event),
591 protocol,
592 status_tx,
593 )
594 .await
595 {
596 Ok(result) => {
597 if let Some(updated) = result.memory_updates {
598 *memory.lock().await = updated.clone();
599 app_state.set_memory_for_client(client_id, updated).await;
600 }
601 run_actions(
602 result.actions,
603 protocol,
604 write_half,
605 app_state,
606 status_tx,
607 client_id,
608 )
609 .await
610 }
611 Err(e) => {
612 error!("STOMP client {client_id} LLM error on {}: {e}", event.id());
617 let _ = status_tx.send(format!(
618 "[CLIENT] ✖ STOMP client {client_id} could not answer {}: {e}",
619 event.id()
620 ));
621 Flow::Continue
622 }
623 }
624}
625
626async fn run_actions(
628 actions: Vec<serde_json::Value>,
629 protocol: &StompClientProtocol,
630 write_half: &Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>,
631 app_state: &Arc<AppState>,
632 status_tx: &mpsc::UnboundedSender<String>,
633 client_id: ClientId,
634) -> Flow {
635 let mut flow = Flow::Continue;
636
637 for action in actions {
638 let is_disconnect = action.get("type").and_then(|v| v.as_str()) == Some("disconnect");
641
642 let result = match protocol.execute_action(action.clone()) {
643 Ok(result) => result,
644 Err(e) => {
645 warn!("STOMP client {client_id} rejected an action from the model: {e}");
646 let _ = status_tx.send(format!(
647 "[CLIENT] ⚠ STOMP client {client_id} rejected an action: {e}"
648 ));
649 continue;
650 }
651 };
652
653 for bytes in result.get_all_data() {
654 let mut guard = write_half.lock().await;
655 if let Err(e) = guard.write_all(&bytes).await {
656 error!("STOMP client {client_id} write failed: {e}");
657 return Flow::Close;
658 }
659 if let Err(e) = guard.flush().await {
660 error!("STOMP client {client_id} flush failed: {e}");
661 return Flow::Close;
662 }
663 drop(guard);
664 trace!("STOMP client {client_id} wrote {} byte(s)", bytes.len());
665 }
666
667 if result.disconnects() {
668 app_state
671 .update_client_status(client_id, ClientStatus::Disconnected)
672 .await;
673 return Flow::Close;
674 }
675 if is_disconnect {
676 flow = Flow::DisconnectSent;
677 }
678 }
679
680 flow
681}
682
683fn build_connect_frame(
688 use_stomp_command: bool,
689 host: &str,
690 login: Option<&str>,
691 passcode: Option<&str>,
692) -> Vec<u8> {
693 let mut headers = vec![
694 (
695 "accept-version".to_string(),
696 STOMP_ACCEPT_VERSION.to_string(),
697 ),
698 ("host".to_string(), host.to_string()),
699 ("heart-beat".to_string(), STOMP_HEARTBEAT.to_string()),
700 ];
701 if let Some(login) = login {
702 headers.push(("login".to_string(), login.to_string()));
703 }
704 if let Some(passcode) = passcode {
705 headers.push(("passcode".to_string(), passcode.to_string()));
706 }
707 let command = if use_stomp_command {
708 "STOMP"
709 } else {
710 "CONNECT"
711 };
712 StompFrame::new(command, headers, Vec::new()).encode()
713}
714
715fn validate_connected(reply: &StompFrame) -> Result<(String, String, String)> {
721 match reply.command.as_str() {
722 "CONNECTED" => {}
723 "ERROR" => {
724 let message = reply.header("message").unwrap_or("(no message header)");
725 bail!(
726 "the broker refused the session with an ERROR frame: {message}. This client \
727 offers accept-version {STOMP_ACCEPT_VERSION} and heart-beat \
728 {STOMP_HEARTBEAT}."
729 );
730 }
731 other => bail!(
732 "expected CONNECTED in reply to CONNECT, got a {other:?} frame. The session is not \
733 open and no frame will be sent on it."
734 ),
735 }
736
737 let Some(version) = reply.header("version") else {
738 bail!(
739 "the broker's CONNECTED frame carries no 'version' header, which STOMP 1.2 \
740 requires. Without it there is no way to know which dialect the following frames \
741 are in, so the session is refused rather than guessed at."
742 );
743 };
744 if version != STOMP_ACCEPT_VERSION {
745 bail!(
746 "the broker chose STOMP version {version:?}, which this client did not offer — its \
747 accept-version was {STOMP_ACCEPT_VERSION:?} and it implements nothing else. \
748 Speaking a dialect we do not implement would corrupt the session rather than \
749 degrade it."
750 );
751 }
752
753 Ok((
754 version.to_string(),
755 reply.header("session").unwrap_or_default().to_string(),
756 reply.header("server").unwrap_or_default().to_string(),
757 ))
758}
759
760async fn read_one_frame<R>(
765 reader: &mut R,
766 pending: &mut Vec<u8>,
767 timeout: Duration,
768) -> Result<StompFrame>
769where
770 R: tokio::io::AsyncRead + Unpin,
771{
772 let deadline = tokio::time::Instant::now() + timeout;
773 let mut buf = vec![0u8; 4096];
774
775 loop {
776 match frame::parse_frame(pending).map_err(|e| anyhow!("{e}"))? {
777 ParseOutcome::Frame { frame, consumed } => {
778 pending.drain(..consumed);
779 return Ok(frame);
780 }
781 ParseOutcome::Heartbeat { consumed } => {
782 pending.drain(..consumed);
783 continue;
784 }
785 ParseOutcome::Incomplete => {}
786 }
787
788 let n = tokio::time::timeout_at(deadline, reader.read(&mut buf))
789 .await
790 .map_err(|_| {
791 anyhow!(
792 "the broker sent no complete frame within {}s ({} byte(s) buffered)",
793 timeout.as_secs(),
794 pending.len()
795 )
796 })?
797 .context("read failed while waiting for the broker's reply")?;
798 if n == 0 {
799 bail!(
800 "the broker closed the connection without replying ({} byte(s) buffered)",
801 pending.len()
802 );
803 }
804 pending.extend_from_slice(&buf[..n]);
805 }
806}
807
808fn host_of(remote_addr: &str) -> String {
814 let trimmed = remote_addr.trim();
815 if let Some(rest) = trimmed.strip_prefix('[') {
816 if let Some(end) = rest.find(']') {
817 return rest[..end].to_string();
818 }
819 }
820 match trimmed.rsplit_once(':') {
821 Some((host, _port)) if !host.is_empty() => host.to_string(),
822 _ => trimmed.to_string(),
823 }
824}
825
826fn body_for_event(body: &[u8]) -> (String, &'static str) {
832 if body
833 .iter()
834 .all(|b| b.is_ascii_graphic() || b.is_ascii_whitespace())
835 {
836 (String::from_utf8_lossy(body).to_string(), "utf8")
837 } else {
838 (hex::encode(body), "hex")
839 }
840}