1pub mod actions;
3
4pub use actions::CassandraClientProtocol;
5
6use crate::llm::actions::client_trait::{Client, ClientActionResult};
7use anyhow::{Context, Result};
8use std::net::SocketAddr;
9use std::sync::Arc;
10use tokio::sync::mpsc;
11use tracing::{debug, error, info, trace, warn};
12
13use crate::client::cassandra::actions::{
14 CASSANDRA_CLIENT_CONNECTED_EVENT, CASSANDRA_CLIENT_RESULT_RECEIVED_EVENT,
15};
16use crate::client::llm_budget::call_llm_for_client;
17use crate::llm::ollama_client::OllamaClient;
18use crate::llm::ClientLlmResult;
19use crate::protocol::Event;
20use crate::state::app_state::AppState;
21use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
22use crate::state::{ClientId, ClientStatus};
23use serde_json::json;
24
25use scylla::client::session::Session;
26use scylla::client::session_builder::SessionBuilder;
27use scylla::frame::Compression;
28
29enum Applied {
31 Query {
33 query: String,
34 rows: Vec<serde_json::Value>,
35 row_count: usize,
36 },
37 Disconnect,
39 Nothing(&'static str),
41}
42
43const MAX_FOLLOWUP_DEPTH: u8 = 6;
51
52fn parse_consistency(name: &str) -> Option<scylla::frame::types::Consistency> {
58 use scylla::frame::types::Consistency;
59 match name.trim().to_ascii_uppercase().as_str() {
60 "ANY" => Some(Consistency::Any),
61 "ONE" => Some(Consistency::One),
62 "TWO" => Some(Consistency::Two),
63 "THREE" => Some(Consistency::Three),
64 "QUORUM" => Some(Consistency::Quorum),
65 "ALL" => Some(Consistency::All),
66 "LOCAL_QUORUM" => Some(Consistency::LocalQuorum),
67 "EACH_QUORUM" => Some(Consistency::EachQuorum),
68 "SERIAL" => Some(Consistency::Serial),
69 "LOCAL_SERIAL" => Some(Consistency::LocalSerial),
70 "LOCAL_ONE" => Some(Consistency::LocalOne),
71 _ => None,
72 }
73}
74
75pub struct CassandraClient;
77
78impl CassandraClient {
79 pub async fn connect_with_llm_actions(
81 remote_addr: String,
82 llm_client: OllamaClient,
83 app_state: Arc<AppState>,
84 status_tx: mpsc::UnboundedSender<String>,
85 client_id: ClientId,
86 startup_params: Option<crate::protocol::StartupParams>,
87 ) -> Result<SocketAddr> {
88 let keyspace = startup_params
90 .as_ref()
91 .map(|p| p.get_optional_string("keyspace"))
92 .transpose()?
93 .flatten();
94
95 let username = startup_params
96 .as_ref()
97 .map(|p| p.get_optional_string("username"))
98 .transpose()?
99 .flatten();
100
101 let password = startup_params
102 .as_ref()
103 .map(|p| p.get_optional_string("password"))
104 .transpose()?
105 .flatten();
106
107 info!(
108 "Cassandra client {} connecting to {}",
109 client_id, remote_addr
110 );
111
112 let mut builder = SessionBuilder::new()
114 .known_node(&remote_addr)
115 .compression(Some(Compression::Lz4));
116
117 if let (Some(user), Some(pass)) = (username, password) {
119 builder = builder.user(&user, &pass);
120 }
121
122 if let Some(ks) = &keyspace {
124 builder = builder.use_keyspace(ks, false);
125 }
126
127 let session = builder
129 .build()
130 .await
131 .context(format!("Failed to connect to Cassandra at {}", remote_addr))?;
132
133 let session_arc = Arc::new(session);
136 let protocol = Arc::new(CassandraClientProtocol::new());
137
138 let socket_addr: SocketAddr = remote_addr
140 .parse()
141 .context(format!("Invalid address format: {}", remote_addr))?;
142
143 info!(
144 "Cassandra client {} connected to {}",
145 client_id, socket_addr
146 );
147
148 app_state
150 .update_client_status(client_id, ClientStatus::Connected)
151 .await;
152 let _ = status_tx.send(format!("[CLIENT] Cassandra client {} connected", client_id));
153 let _ = status_tx.send("__UPDATE_UI__".to_string());
154
155 let command_rx =
160 crate::client::command_support::register_command_channel(&app_state, client_id).await;
161 let cmd_task = tokio::spawn(Self::command_loop(
162 command_rx,
163 protocol.clone(),
164 session_arc.clone(),
165 client_id,
166 llm_client.clone(),
167 app_state.clone(),
168 status_tx.clone(),
169 ));
170 app_state.register_client_task(client_id, cmd_task).await;
171
172 if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
174 let event = Event::new(
175 &CASSANDRA_CLIENT_CONNECTED_EVENT,
176 json!({
177 "remote_addr": remote_addr,
178 }),
179 );
180
181 let memory = app_state
182 .get_memory_for_client(client_id)
183 .await
184 .unwrap_or_default();
185
186 match call_llm_for_client(
188 &llm_client,
189 &app_state,
190 client_id.to_string(),
191 &instruction,
192 &memory,
193 Some(&event),
194 protocol.as_ref(),
195 &status_tx,
196 )
197 .await
198 {
199 Ok(ClientLlmResult {
200 actions,
201 memory_updates,
202 }) => {
203 if let Some(mem) = memory_updates {
205 app_state.set_memory_for_client(client_id, mem).await;
206 }
207
208 Self::execute_actions(
210 actions,
211 protocol.clone(),
212 session_arc.clone(),
213 client_id,
214 llm_client.clone(),
215 app_state.clone(),
216 status_tx.clone(),
217 0,
218 )
219 .await;
220 }
221 Err(e) => {
222 error!(
223 "Initial LLM call error for Cassandra client {}: {}",
224 client_id, e
225 );
226 }
227 }
228 }
229
230 Ok(socket_addr)
231 }
232
233 #[allow(clippy::too_many_arguments)]
240 async fn command_loop(
241 mut command_rx: mpsc::Receiver<ClientCommand>,
242 protocol: Arc<CassandraClientProtocol>,
243 session: Arc<Session>,
244 client_id: ClientId,
245 llm_client: OllamaClient,
246 app_state: Arc<AppState>,
247 status_tx: mpsc::UnboundedSender<String>,
248 ) {
249 use crate::llm::actions::protocol_trait::Protocol;
250 use crate::state::AccessLogOwner;
251
252 while let Some(command) = command_rx.recv().await {
253 let action = command.action.clone();
254
255 let mut follow_up: Option<(Vec<serde_json::Value>, usize)> = None;
256 let outcome = match protocol.execute_action(action.clone()) {
257 Err(e) => Ok(ClientSendOutcome::Rejected {
258 error: e.to_string(),
259 }),
260 Ok(result) => match Self::apply_action(result, &session, client_id).await {
261 Err(e) => Err(e),
262 Ok(Applied::Disconnect) => Ok(ClientSendOutcome::Disconnected),
263 Ok(Applied::Nothing(what)) => Ok(ClientSendOutcome::Executed {
264 detail: what.to_string(),
265 }),
266 Ok(Applied::Query {
267 query,
268 rows,
269 row_count,
270 }) => {
271 let detail = format!(
272 "CQL executed by the scylla driver ({} rows); \
273 no byte count — the driver owns the socket: {}",
274 row_count,
275 crate::utils::truncate::truncate_for_log(&query, 80)
276 );
277 follow_up = Some((rows, row_count));
278 Ok(ClientSendOutcome::Executed { detail })
279 }
280 },
281 };
282
283 let outcome_json = match &outcome {
284 Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
285 Err(e) => serde_json::json!({"error": e.to_string()}),
286 };
287 app_state
288 .record_access_log(
289 AccessLogOwner::Client(client_id.as_u32()),
290 protocol.protocol_name(),
291 None,
292 "injected_action",
293 action,
294 vec![outcome_json],
295 )
296 .await;
297
298 let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
299 if let Err(e) = &outcome {
300 error!(
301 "Cassandra client {} injected action failed: {}",
302 client_id, e
303 );
304 let _ = status_tx.send(format!(
305 "[WARN] Client {} injected action failed: {}",
306 client_id, e
307 ));
308 }
309 let _ = status_tx.send("__UPDATE_UI__".to_string());
310 crate::client::command_support::reply(command, outcome);
311
312 if disconnect {
313 Self::mark_disconnected(client_id, &app_state, &status_tx).await;
314 break;
315 }
316
317 if let Some((rows, row_count)) = follow_up {
320 Self::report_result(
321 rows,
322 row_count,
323 &protocol,
324 &session,
325 client_id,
326 &llm_client,
327 &app_state,
328 &status_tx,
329 0,
330 )
331 .await;
332 }
333 }
334
335 app_state.remove_client_handle(client_id).await;
336 let _ = status_tx.send("__UPDATE_UI__".to_string());
337 }
338
339 #[allow(clippy::too_many_arguments)]
344 async fn execute_actions(
345 actions: Vec<serde_json::Value>,
346 protocol: Arc<CassandraClientProtocol>,
347 session: Arc<Session>,
348 client_id: ClientId,
349 llm_client: OllamaClient,
350 app_state: Arc<AppState>,
351 status_tx: mpsc::UnboundedSender<String>,
352 depth: u8,
353 ) {
354 for action in actions {
355 let result = match protocol.execute_action(action) {
356 Ok(result) => result,
357 Err(e) => {
358 error!("Cassandra client {} action error: {}", client_id, e);
359 continue;
360 }
361 };
362
363 match Self::apply_action(result, &session, client_id).await {
364 Ok(Applied::Query {
365 rows,
366 row_count,
367 query,
368 }) => {
369 if depth >= MAX_FOLLOWUP_DEPTH {
370 warn!(
371 "Cassandra client {} stopped a follow-up chain at depth {}: '{}' \
372 returned {} row(s) but the result is not being reported, so the \
373 model will not answer it",
374 client_id, depth, query, row_count
375 );
376 let _ = status_tx.send(format!(
377 "[WARN] Cassandra client {} hit the follow-up depth limit ({}); the \
378 last query ran but its rows were not reported",
379 client_id, MAX_FOLLOWUP_DEPTH
380 ));
381 } else {
382 Self::report_result(
383 rows,
384 row_count,
385 &protocol,
386 &session,
387 client_id,
388 &llm_client,
389 &app_state,
390 &status_tx,
391 depth,
392 )
393 .await;
394 }
395 }
396 Ok(Applied::Disconnect) => {
397 info!("Cassandra client {} disconnecting", client_id);
398 Self::mark_disconnected(client_id, &app_state, &status_tx).await;
399 break;
400 }
401 Ok(Applied::Nothing(what)) => {
402 debug!(
403 "Cassandra client {} action had no effect: {}",
404 client_id, what
405 );
406 }
407 Err(e) => {
408 error!("Cassandra client {} query error: {}", client_id, e);
409 let _ = status_tx.send(format!("[CLIENT] Cassandra query error: {}", e));
410 }
411 }
412 }
413 }
414
415 async fn apply_action(
418 result: ClientActionResult,
419 session: &Arc<Session>,
420 client_id: ClientId,
421 ) -> Result<Applied> {
422 match result {
423 ClientActionResult::Custom { name, data } if name == "cql_query" => {
424 let query_str = data
425 .get("query")
426 .and_then(|v| v.as_str())
427 .context("Missing 'query' in cql_query action data")?
428 .to_string();
429
430 let mut statement =
439 scylla::statement::unprepared::Statement::new(query_str.clone());
440 let requested = data.get("consistency").and_then(|v| v.as_str());
441 if let Some(name) = requested {
442 let level = parse_consistency(name).ok_or_else(|| {
445 anyhow::anyhow!(
446 "unknown consistency level '{name}'; use one of ANY, ONE, TWO, \
447 THREE, QUORUM, ALL, LOCAL_QUORUM, EACH_QUORUM, SERIAL, \
448 LOCAL_SERIAL, LOCAL_ONE"
449 )
450 })?;
451 statement.set_consistency(level);
452 }
453 debug!(
454 "Cassandra client {} executing query (consistency {}): {}",
455 client_id,
456 requested.unwrap_or("session default"),
457 query_str
458 );
459
460 let query_result = session
461 .query_unpaged(statement, &[])
462 .await
463 .context("CQL query failed")?;
464
465 let (rows, row_count) = {
468 use scylla::value::Row;
469 match query_result.into_rows_result() {
470 Ok(rows_result) => match rows_result.rows::<Row>() {
471 Ok(rows_iter) => {
472 let collected: Vec<_> =
473 rows_iter.collect::<Result<Vec<_>, _>>().unwrap_or_default();
474 let count = collected.len();
475 let data: Vec<serde_json::Value> = collected
476 .into_iter()
477 .map(|row| {
478 let columns: Vec<String> = (0..row.columns.len())
479 .map(|i| format!("{:?}", row.columns[i]))
480 .collect();
481 json!({ "columns": columns })
482 })
483 .collect();
484 (data, count)
485 }
486 Err(e) => {
487 debug!(
488 "Cassandra client {} result deserialization error: {}",
489 client_id, e
490 );
491 (
492 vec![json!({
493 "message": "Query succeeded but result parsing not supported for this schema",
494 "error": format!("{}", e),
495 })],
496 0,
497 )
498 }
499 },
500 Err(e) => {
501 debug!(
503 "Cassandra client {} query succeeded (non-SELECT): {}",
504 client_id, e
505 );
506 (Vec::new(), 0)
507 }
508 }
509 };
510 trace!("Cassandra client {} received {} rows", client_id, row_count);
511
512 Ok(Applied::Query {
513 query: query_str,
514 rows,
515 row_count,
516 })
517 }
518 ClientActionResult::Custom { name, .. } => {
519 debug!(
520 "Cassandra client {} ignoring custom result '{}'",
521 client_id, name
522 );
523 Ok(Applied::Nothing("custom result not handled by this client"))
524 }
525 ClientActionResult::Disconnect => Ok(Applied::Disconnect),
526 ClientActionResult::WaitForMore => Ok(Applied::Nothing("wait_for_more")),
527 ClientActionResult::NoAction => Ok(Applied::Nothing("no_action")),
528 ClientActionResult::SendData(_) => Ok(Applied::Nothing(
529 "send_data is not meaningful for a scylla session",
530 )),
531 ClientActionResult::Multiple(_) => Ok(Applied::Nothing(
532 "multiple results not handled by this client",
533 )),
534 }
535 }
536
537 #[allow(clippy::too_many_arguments)]
539 #[allow(clippy::too_many_arguments)]
540 async fn report_result(
541 rows_data: Vec<serde_json::Value>,
542 row_count: usize,
543 protocol: &Arc<CassandraClientProtocol>,
544 session: &Arc<Session>,
545 client_id: ClientId,
546 llm_client: &OllamaClient,
547 app_state: &Arc<AppState>,
548 status_tx: &mpsc::UnboundedSender<String>,
549 depth: u8,
550 ) {
551 let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
552 return;
553 };
554
555 let event = Event::new(
556 &CASSANDRA_CLIENT_RESULT_RECEIVED_EVENT,
557 json!({
558 "rows": rows_data,
559 "row_count": row_count,
560 }),
561 );
562
563 let memory = app_state
564 .get_memory_for_client(client_id)
565 .await
566 .unwrap_or_default();
567
568 match call_llm_for_client(
569 llm_client,
570 app_state,
571 client_id.to_string(),
572 &instruction,
573 &memory,
574 Some(&event),
575 protocol.as_ref(),
576 status_tx,
577 )
578 .await
579 {
580 Ok(ClientLlmResult {
581 actions: next_actions,
582 memory_updates,
583 }) => {
584 if let Some(mem) = memory_updates {
586 app_state.set_memory_for_client(client_id, mem).await;
587 }
588
589 Box::pin(Self::execute_actions(
593 next_actions,
594 protocol.clone(),
595 session.clone(),
596 client_id,
597 llm_client.clone(),
598 app_state.clone(),
599 status_tx.clone(),
600 depth + 1,
601 ))
602 .await;
603 }
604 Err(e) => {
605 error!("LLM error for Cassandra client {}: {}", client_id, e);
606 }
607 }
608 }
609
610 async fn mark_disconnected(
611 client_id: ClientId,
612 app_state: &Arc<AppState>,
613 status_tx: &mpsc::UnboundedSender<String>,
614 ) {
615 app_state
616 .update_client_status(client_id, ClientStatus::Disconnected)
617 .await;
618 let _ = status_tx.send(format!(
619 "[CLIENT] Cassandra client {} disconnected",
620 client_id
621 ));
622 let _ = status_tx.send("__UPDATE_UI__".to_string());
623 }
624}