Skip to main content

headless_lms_chatbot/azure_chatbot/turn/
mod.rs

1//! The turn driver: keeps asking Azure while a round answers itself with tool calls, and ends on
2//! an answer, an error, a suspension, or the round budget.
3
4mod 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
47/// How many LLM requests one turn may make, bounding a model that keeps calling tools instead of
48/// answering.
49const MAX_TOOL_CALL_ROUNDS_PER_TURN: u32 = 15;
50
51/// Starts a turn for a new user message, and streams its NDJSON events to the client.
52pub 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
75/// Records a client's answer to a tool call the turn suspended on, and continues that turn once
76/// nothing else is outstanding.
77///
78/// Of a round of parallel calls, only the request that answers the last one gets the resumed turn;
79/// the others get a stream carrying `Suspended` again, so a client reads every response the same
80/// way. `tool_call_id` must be a client-answered call of `conversation_id` that has no answer yet
81/// and `answer` must fit what that call offered, or this fails with
82/// [ChatbotErrorType::InvalidToolAnswer] and writes nothing.
83pub 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
106/// What starts a turn: a new message from the user, or one resuming after the client answered a
107/// tool call the previous turn suspended on.
108enum 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
121/// Shared preamble of both ways a turn can begin: acquire a connection, repair any tool call a
122/// dead turn of this conversation left unanswered, build the request the turn runs with, and hand
123/// off to [stream_turn] — or, on a resume that is still waiting on another call, return the
124/// [ChatbotChatStreamEvent::Suspended] stream without ever reaching it.
125///
126/// Repairing before either path reads the conversation's history is required, not incidental: an
127/// unanswered call from a dead turn makes the LLM reject every later message of the conversation.
128/// Only long-dead calls are touched: another request may be streaming a turn of this same
129/// conversation.
130async 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            // One transaction for the answer path: a confirmed action tool's mutation, its audit
164            // row (both inside `client_tool_output_for_answer`), and the recorded tool output
165            // below commit or roll back together, so the transcript can never claim an effect the
166            // database does not have.
167            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                // Another client tool call is still open, so there's no resumed turn to ride the
197                // payload ahead of -- emit it here or it's lost, and the browser never sees it.
198                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            // The reset link (or similar) an executed action tool produced is for this browser
226            // only and is never persisted, so it can only reach the client by riding ahead of the
227            // resumed turn's own stream.
228            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
258/// What a round that ended in error becomes: logs it, answers whatever tool call the turn left
259/// without an output, and either returns the wire event for an error the turn survives or the
260/// original error for one that ends it.
261///
262/// The reap belongs here rather than at each failing site, so that no error path can end a turn
263/// without it: a call with no output makes the LLM reject every later message of the conversation.
264/// `response_ids` are the responses this turn's rounds were given, which keeps the reap off the
265/// calls of a turn streaming in another request.
266///
267/// Takes the pool rather than a connection: the call sites hold their round's connection under a
268/// live borrow, or have already given theirs back, so this acquires its own for the reap.
269async 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
306/// Records a failure that killed a turn in `error_variants`/`error_occurrences`.
307///
308/// A streaming response has already sent its headers by the time a round can fail, so actix never
309/// calls `ResponseError::error_response` for it and the backend's usual reporting path sees none
310/// of these -- without this they exist only as a log line. Best effort: the turn is already being
311/// torn down, so a failure to report is logged and otherwise ignored.
312async 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
332/// What every chatbot stream failure records beyond its message, so one query finds them all and
333/// each row says which turn it belongs to.
334///
335/// `input` is the item chain the failed round was sent. It is the only thing that names the tool
336/// call a failure belongs to once that call's row has died with the transaction that failed to
337/// store it.
338fn 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
353/// Builds the wire event for a round-ending error, folding in the input summary and response ids
354/// every call site of [recover_from_round_error] otherwise repeats.
355async 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
374/// Runs the request rounds of one turn against the LLM and streams its events as NDJSON.
375///
376/// Keeps asking the LLM as long as a round ends in tool calls it answered itself, and ends the
377/// turn on a text answer, an error, a suspension, or the iteration limit. Owns the cancellation
378/// guard, so a client that disappears mid-turn still gets what arrived saved.
379fn 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    // Shared with the guard so that its cleanup answers this turn's tool calls and no other
392    // turn's.
393    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                // Not a ChatbotError, but it ends a turn as thoroughly as one and is just as
410                // invisible outside the logs, so it is recorded the same way.
411                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            // One statement, so no guard is alive across the awaits below: `?` inside `try_stream!`
452            // parks the generator rather than returning, and a guard held at one is never released.
453            response_ids.lock().await.push(received_response_id.clone());
454
455            // Acquired only now: the request and its classification need no database, and the pool
456            // is shared with the rest of the application.
457            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                        // A function call classifies the round it opens, so it arrives here rather
464                        // than in the round that runs it; hand it over to be recorded there.
465                        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            // Some only for a round that streams an answer, which is the only round whose events
490            // address a message of their own.
491            let (mut final_stream, text_message_id) = match typed_response_stream {
492                ResponseStreamType::ToolCall(stream) => {
493                    // The round writes a row per call as it goes, so it keeps the connection.
494                    (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                    // One statement, so the guard is not alive across the awaits below: `?` inside
513                    // `try_stream!` parks the generator rather than returning, and a guard held at
514                    // one is never released.
515                    *response_message_id.lock().await = Some(response_message.id);
516
517                    // Move the citations of the turn onto the message that cites them before its
518                    // text reaches the learner, so the markers in it have something behind them.
519                    models::chatbot_conversation_messages_citations::attach_turn_citations_to_message(
520                        &mut conn,
521                        conversation_id,
522                        response_message.id,
523                    ).await?;
524
525                    // Given back before the answer streams, which takes as long as the model takes
526                    // to write it; what little the loop below stores acquires its own.
527                    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                                // Nothing ever reached this message, and an empty one that is
542                                // never completed replays into every later turn. Cleared first so
543                                // the cancellation guard does not try to clean it up again.
544                                *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                        // A `Message` among these is an unexpected wire shape, not a normal
569                        // path; store_output_item errors on it rather than storing it.
570                        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                            // A search output stored after the answer's message was created keeps
576                            // its citations on the tool-output row, out of reach of the markers in
577                            // the answer that cite them.
578                            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                        // Stored by the round that produced it, at its position among that
595                        // round's other items (see `TurnEvent::ItemAnnounced`); this only converts
596                        // and forwards it to the client.
597                        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                        // The turn ended on purpose, so the guard must not treat the conversation
625                        // as one that died mid-answer and clean up after it.
626                        done.store(true, atomic::Ordering::Relaxed);
627                        break 'outer;
628                    }
629                }
630            }
631        }
632    };
633
634    Box::pin(GuardedStream::new(guard, response_stream))
635}