1mod cancellation;
5mod round;
6mod text_response;
7
8use std::pin::Pin;
9use std::sync::{
10 Arc,
11 atomic::{self, AtomicBool},
12};
13
14use bytes::Bytes;
15use futures::{Stream, StreamExt};
16use headless_lms_base::config::ApplicationConfiguration;
17use headless_lms_models::chatbot_conversation_message_messages::{
18 ChatbotConversationMessageMessage, MessageRole,
19};
20use headless_lms_models::chatbot_conversation_messages::{ChatbotConversationMessage, Message};
21use sqlx::PgPool;
22use tokio::sync::Mutex;
23use tracing::trace;
24
25use super::azure::protocol::{LLMRequest, OutputItem};
26use super::azure::sse::detect_response_kind;
27use super::azure::transport::{ResponseStreamType, make_request_and_create_stream};
28use super::client_tool_calls::answer::{client_tool_output_for_answer, rejected_tool_answer_error};
29use super::client_tool_calls::repair::{
30 answer_stale_unfinished_tool_calls, answer_unfinished_tool_calls,
31};
32use super::events::{
33 ChatbotChatStreamEvent, StreamItem, TurnEvent, error_event_from_error, error_event_from_text,
34 ndjson_line, single_event_stream, stream_event_for,
35};
36use super::request::replayable_input_message;
37use crate::chatbot_error::ChatbotResult;
38use crate::chatbot_tools::ClientToolAnswer;
39use crate::conversation_context::ChatbotPageContext;
40use crate::llm_utils::{estimate_tokens, summarize_input_for_log};
41use crate::prelude::*;
42use crate::user_context::ChatbotTurnContext;
43use cancellation::{GuardedStream, RequestCancelledGuard, save_partial_answer};
44use round::{is_stored_by_round, parse_tool, store_output_item};
45use text_response::parse_text_response;
46
47const MAX_TOOL_CALL_ROUNDS_PER_TURN: u32 = 15;
50
51pub async fn send_chat_request_and_parse_stream(
53 pool: PgPool,
54 app_configuration: &ApplicationConfiguration,
55 chatbot_configuration_id: Uuid,
56 conversation_id: Uuid,
57 message: &str,
58 page_context: Option<ChatbotPageContext>,
59 user_context: ChatbotTurnContext,
60) -> ChatbotResult<Pin<Box<dyn Stream<Item = ChatbotResult<Bytes>> + Send>>> {
61 begin_turn(
62 pool,
63 app_configuration,
64 conversation_id,
65 user_context,
66 TurnStart::NewUserMessage {
67 chatbot_configuration_id,
68 message,
69 page_context,
70 },
71 )
72 .await
73}
74
75pub async fn answer_tool_call_and_resume_stream(
84 pool: PgPool,
85 app_configuration: &ApplicationConfiguration,
86 chatbot_configuration_id: Uuid,
87 conversation_id: Uuid,
88 tool_call_id: &str,
89 answer: &ClientToolAnswer,
90 user_context: ChatbotTurnContext,
91) -> ChatbotResult<Pin<Box<dyn Stream<Item = ChatbotResult<Bytes>> + Send>>> {
92 begin_turn(
93 pool,
94 app_configuration,
95 conversation_id,
96 user_context,
97 TurnStart::ResumedFromToolAnswer {
98 chatbot_configuration_id,
99 tool_call_id,
100 answer,
101 },
102 )
103 .await
104}
105
106enum TurnStart<'a> {
109 NewUserMessage {
110 chatbot_configuration_id: Uuid,
111 message: &'a str,
112 page_context: Option<ChatbotPageContext>,
113 },
114 ResumedFromToolAnswer {
115 chatbot_configuration_id: Uuid,
116 tool_call_id: &'a str,
117 answer: &'a ClientToolAnswer,
118 },
119}
120
121async fn begin_turn(
131 pool: PgPool,
132 app_configuration: &ApplicationConfiguration,
133 conversation_id: Uuid,
134 user_context: ChatbotTurnContext,
135 start: TurnStart<'_>,
136) -> ChatbotResult<Pin<Box<dyn Stream<Item = ChatbotResult<Bytes>> + Send>>> {
137 let mut conn = pool.acquire().await?;
138 let unanswered = answer_stale_unfinished_tool_calls(&mut conn, conversation_id).await?;
139 let app_config = app_configuration.to_owned();
140
141 let chat_request = match start {
142 TurnStart::NewUserMessage {
143 chatbot_configuration_id,
144 message,
145 page_context,
146 } => {
147 LLMRequest::build_and_insert_incoming_user_message_to_db(
148 &mut conn,
149 chatbot_configuration_id,
150 conversation_id,
151 message,
152 page_context,
153 &user_context,
154 &app_config,
155 )
156 .await?
157 }
158 TurnStart::ResumedFromToolAnswer {
159 chatbot_configuration_id,
160 tool_call_id,
161 answer,
162 } => {
163 let mut tx = conn.begin().await?;
168
169 let answered = client_tool_output_for_answer(
170 &mut tx,
171 &app_config,
172 conversation_id,
173 &unanswered,
174 tool_call_id,
175 answer,
176 &user_context,
177 )
178 .await?;
179
180 let outcome = models::chatbot_conversation_messages::answer_client_tool_call(
181 &mut tx,
182 conversation_id,
183 tool_call_id,
184 answered.output,
185 answered.client_answer,
186 )
187 .await
188 .map_err(rejected_tool_answer_error)?;
189
190 tx.commit().await?;
191
192 if !outcome.turn_can_resume {
193 trace!(
194 "Tool call {tool_call_id} answered, the turn is still waiting for another answer"
195 );
196 if let Some(payload) = answered.execution_payload {
199 let event = ChatbotChatStreamEvent::ActionExecuted {
200 tool_call_id: tool_call_id.to_string(),
201 payload,
202 };
203 let action_line = ndjson_line(&event)?;
204 let suspended_line = ndjson_line(&ChatbotChatStreamEvent::Suspended)?;
205 return Ok(Box::pin(futures::stream::iter([
206 Ok(action_line),
207 Ok(suspended_line),
208 ])));
209 }
210 return single_event_stream(ChatbotChatStreamEvent::Suspended);
211 }
212
213 let configuration =
214 models::chatbot_configurations::get_by_id(&mut conn, chatbot_configuration_id)
215 .await?;
216 let chat_request = LLMRequest::build_from_conversation(
217 &mut conn,
218 &configuration,
219 conversation_id,
220 &user_context,
221 &app_config,
222 )
223 .await?;
224
225 if let Some(payload) = answered.execution_payload {
229 let event = ChatbotChatStreamEvent::ActionExecuted {
230 tool_call_id: tool_call_id.to_string(),
231 payload,
232 };
233 let line = ndjson_line(&event)?;
234 return Ok(Box::pin(
235 futures::stream::once(async move { Ok(line) }).chain(stream_turn(
236 pool,
237 app_config,
238 conversation_id,
239 chat_request,
240 user_context,
241 )),
242 ));
243 }
244
245 chat_request
246 }
247 };
248
249 Ok(stream_turn(
250 pool,
251 app_config,
252 conversation_id,
253 chat_request,
254 user_context,
255 ))
256}
257
258async fn recover_from_round_error(
270 pool: &PgPool,
271 conversation_id: Uuid,
272 response_ids: &[String],
273 input_summary: &str,
274 error: ChatbotError,
275) -> ChatbotResult<Bytes> {
276 let response_id = response_ids.last().map(String::as_str);
277 error!(
278 input = %input_summary,
279 "Stream ended unexpectedly. Response id: {} Error: {}", response_id.unwrap_or("not received"), error
280 );
281 let mut conn = pool.acquire().await?;
282 report_stream_failure(
283 &mut conn,
284 error.message().to_string(),
285 Some(format!("{error:?}")),
286 stream_failure_details(
287 &format!("{:?}", error.error_type()),
288 conversation_id,
289 response_id,
290 input_summary,
291 ),
292 )
293 .await;
294 if let Err(e2) = answer_unfinished_tool_calls(&mut conn, conversation_id, response_ids).await {
295 error!(
296 "Error in chatbot streaming and couldn't answer unfinished tool calls: {e2}. Response id: {}",
297 response_id.unwrap_or("not received")
298 );
299 }
300 if error.error_type().should_terminate_stream() {
301 return Err(error);
302 }
303 error_event_from_error(&error)
304}
305
306async fn report_stream_failure(
313 conn: &mut PgConnection,
314 message: String,
315 stack_trace: Option<String>,
316 details: serde_json::Value,
317) {
318 let report = models::errors::NewErrorReport {
319 service: "headless-lms".to_string(),
320 error_source: Some(models::errors::ErrorSource::Backend),
321 message,
322 stack_trace,
323 path: None,
324 app_version: None,
325 details: Some(details),
326 };
327 if let Err(e) = models::errors::insert(conn, None, &report).await {
328 warn!("Could not record the chatbot stream failure: {e}");
329 }
330}
331
332fn stream_failure_details(
339 kind: &str,
340 conversation_id: Uuid,
341 response_id: Option<&str>,
342 input_summary: &str,
343) -> serde_json::Value {
344 serde_json::json!({
345 "kind": "chatbot_stream_error",
346 "chatbot_error_type": kind,
347 "conversation_id": conversation_id,
348 "response_id": response_id,
349 "input": input_summary,
350 })
351}
352
353async fn recover_and_summarize(
356 pool: &PgPool,
357 conversation_id: Uuid,
358 response_ids: &Mutex<Vec<String>>,
359 input: &[crate::llm_utils::APIInputMessage],
360 error: ChatbotError,
361) -> ChatbotResult<Bytes> {
362 let input_summary = summarize_input_for_log(input);
363 let round_response_ids = response_ids.lock().await.clone();
364 recover_from_round_error(
365 pool,
366 conversation_id,
367 &round_response_ids,
368 &input_summary,
369 error,
370 )
371 .await
372}
373
374fn stream_turn(
380 pool: PgPool,
381 app_config: ApplicationConfiguration,
382 conversation_id: Uuid,
383 mut chat_request: LLMRequest,
384 user_context: ChatbotTurnContext,
385) -> Pin<Box<dyn Stream<Item = ChatbotResult<Bytes>> + Send>> {
386 let mut rounds_left = MAX_TOOL_CALL_ROUNDS_PER_TURN;
387
388 let done = Arc::new(AtomicBool::new(false));
389 let full_response_text = Arc::new(Mutex::new(String::new()));
390 let response_message_id: Arc<Mutex<Option<Uuid>>> = Arc::new(Mutex::new(None));
391 let response_ids: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
394
395 let guard = RequestCancelledGuard {
396 conversation_id,
397 response_ids: response_ids.clone(),
398 response_message_id: response_message_id.clone(),
399 full_response_text: full_response_text.clone(),
400 pool: pool.clone(),
401 done: done.clone(),
402 };
403
404 let response_stream = async_stream::try_stream! {
405 'outer: loop {
406 if rounds_left == 0 {
407 const ROUND_LIMIT_MESSAGE: &str = "Maximum tool call iterations exceeded";
408 error!("{ROUND_LIMIT_MESSAGE}");
409 if let Ok(mut conn) = pool.acquire().await {
412 report_stream_failure(
413 &mut conn,
414 ROUND_LIMIT_MESSAGE.to_string(),
415 None,
416 stream_failure_details(
417 "RoundLimitExceeded",
418 conversation_id,
419 response_ids.lock().await.last().map(String::as_str),
420 &summarize_input_for_log(&chat_request.input),
421 ),
422 )
423 .await;
424 }
425 yield error_event_from_text("Maximum tool call iterations exceeded. The LLM may be stuck in a loop.")?;
426 done.store(true, atomic::Ordering::Relaxed);
427 break 'outer;
428 }
429 rounds_left -= 1;
430
431 let lines = match make_request_and_create_stream(&chat_request, &app_config).await {
432 Ok(val) => val,
433 Err(error) => {
434 let event = recover_and_summarize(&pool, conversation_id, &response_ids, &chat_request.input, error).await?;
435 yield event;
436 done.store(true, atomic::Ordering::Relaxed);
437 break 'outer;
438 },
439 };
440 let classified = match detect_response_kind(lines).await {
441 Ok(classified) => classified,
442 Err(e) => {
443 let event = recover_and_summarize(&pool, conversation_id, &response_ids, &chat_request.input, e).await?;
444 yield event;
445 done.store(true, atomic::Ordering::Relaxed);
446 break 'outer;
447 },
448 };
449 let received_response_id = classified.response_id;
450 let typed_response_stream = classified.stream;
451 response_ids.lock().await.push(received_response_id.clone());
454
455 let mut conn = pool.acquire().await?;
458
459 let mut calls_from_classification = Vec::new();
460 for stream_item in classified.items {
461 if let StreamItem::Received { item, finished: true } = &stream_item {
462 if is_stored_by_round(item) {
463 calls_from_classification.push(item.to_owned());
466 } else {
467 let stored = match store_output_item(&mut conn, item.to_owned(), conversation_id, &app_config)
468 .await
469 .and_then(replayable_input_message)
470 {
471 Ok(stored) => stored,
472 Err(e) => {
473 let event = recover_and_summarize(&pool, conversation_id, &response_ids, &chat_request.input, e).await?;
474 yield event;
475 done.store(true, atomic::Ordering::Relaxed);
476 break 'outer;
477 }
478 };
479 if let Some(stored) = stored {
480 chat_request.input.push(stored);
481 }
482 }
483 }
484 if let Some(event) = stream_event_for(stream_item) {
485 yield ndjson_line(&event)?;
486 };
487 }
488
489 let (mut final_stream, text_message_id) = match typed_response_stream {
492 ResponseStreamType::ToolCall(stream) => {
493 (parse_tool(conn, &app_config, stream, conversation_id, received_response_id, &user_context, calls_from_classification).await, None)
495 }
496 ResponseStreamType::TextResponse(stream) => {
497 let response_message = models::chatbot_conversation_messages::insert(
498 &mut conn,
499 ChatbotConversationMessage {
500 conversation_id,
501 message: Message::Text(ChatbotConversationMessageMessage {
502 text: "".to_string(),
503 message_role: MessageRole::Assistant,
504 message_is_complete: false,
505 response_id: Some(received_response_id.clone()),
506 ..Default::default()
507 }),
508 ..Default::default()
509 },
510 ).await?;
511
512 *response_message_id.lock().await = Some(response_message.id);
516
517 models::chatbot_conversation_messages_citations::attach_turn_citations_to_message(
520 &mut conn,
521 conversation_id,
522 response_message.id,
523 ).await?;
524
525 drop(conn);
528
529 (parse_text_response(stream, full_response_text.clone(), received_response_id).await, Some(response_message.id))
530 }
531 };
532
533 while let Some(line) = final_stream.next().await {
534 let val = match line {
535 Ok(val) => val,
536 Err(e) => {
537 if let Some(message_id) = text_message_id {
538 let full_response_as_string = full_response_text.lock().await.clone();
539 let mut conn = pool.acquire().await?;
540 if full_response_as_string.is_empty() {
541 *response_message_id.lock().await = None;
545 models::chatbot_conversation_messages::delete(&mut conn, message_id).await?;
546 } else {
547 let used_tokens = estimate_tokens(&full_response_as_string);
548 save_partial_answer(&mut conn, message_id, &full_response_as_string, used_tokens).await?;
549 }
550 };
551 let event = recover_and_summarize(&pool, conversation_id, &response_ids, &chat_request.input, e).await?;
552 yield event;
553 done.store(true, atomic::Ordering::Relaxed);
554 break 'outer;
555 }
556 };
557 match val {
558 TurnEvent::Delta(text) => {
559 match text_message_id {
560 Some(message_id) => yield ndjson_line(&ChatbotChatStreamEvent::Delta { text, message_id })?,
561 None => Err(chatbot_err!(StreamInvariantViolation, "Received answer text from a round that streams no answer."))?,
562 }
563 },
564 TurnEvent::Refusal { text, message_id } => {
565 yield ndjson_line(&ChatbotChatStreamEvent::Delta { text, message_id })?;
566 },
567 TurnEvent::Item(stream_item) => {
568 if let StreamItem::Received { item, finished: true } = &stream_item
571 && !is_stored_by_round(item)
572 {
573 let mut conn = pool.acquire().await?;
574 store_output_item(&mut conn, item.to_owned(), conversation_id, &app_config).await?;
575 if let Some(message_id) = text_message_id
579 && matches!(item, OutputItem::AzureAiSearchCallOutput { .. })
580 {
581 models::chatbot_conversation_messages_citations::attach_turn_citations_to_message(
582 &mut conn,
583 conversation_id,
584 message_id,
585 ).await?;
586 }
587 }
588
589 if let Some(response) = stream_event_for(stream_item) {
590 yield ndjson_line(&response)?;
591 };
592 },
593 TurnEvent::ItemAnnounced(item) => {
594 if let Some(response) = stream_event_for(StreamItem::Received { item, finished: true }) {
598 yield ndjson_line(&response)?;
599 };
600 },
601 TurnEvent::Messages(messages) => {
602 chat_request.input.extend(messages);
603 },
604 TurnEvent::Done { text, used_tokens } => {
605 match text_message_id {
606 Some(message_id) => {
607 let mut conn = pool.acquire().await?;
608 models::chatbot_conversation_messages::update(
609 &mut conn,
610 message_id,
611 &text,
612 true,
613 used_tokens,
614 ).await?;
615 }
616 None => Err(chatbot_err!(StreamInvariantViolation, "A round that streams no answer reported one finished."))?,
617 }
618 done.store(true, atomic::Ordering::Relaxed);
619 yield ndjson_line(&ChatbotChatStreamEvent::Done)?;
620 break 'outer;
621 }
622 TurnEvent::Suspended => {
623 yield ndjson_line(&ChatbotChatStreamEvent::Suspended)?;
624 done.store(true, atomic::Ordering::Relaxed);
627 break 'outer;
628 }
629 }
630 }
631 }
632 };
633
634 Box::pin(GuardedStream::new(guard, response_stream))
635}