Skip to main content

headless_lms_chatbot/azure_chatbot/turn/
round.rs

1//! One tool-call round: running the calls the server answers, storing each beside its output, and
2//! handing the round's items on to the next request.
3
4use std::ops::DerefMut;
5
6use futures::StreamExt;
7use futures::stream::BoxStream;
8use headless_lms_base::config::ApplicationConfiguration;
9use headless_lms_models::chatbot_conversation_messages::{self, ChatbotConversationMessage};
10use headless_lms_models::chatbot_conversation_messages_citations::{
11    self, ChatbotConversationMessageCitation,
12};
13use tracing::trace;
14use url::Url;
15
16use crate::azure_chatbot::azure::protocol::{
17    AISearchOutput, OutputItem, ReceivedOutputItem, ResponseOutput, check_response_complete,
18    check_response_output,
19};
20use crate::azure_chatbot::azure::sse::{AzureStreamEvent, ParsedResponseLine};
21use crate::azure_chatbot::azure::transport::ResponseLinesStream;
22use crate::azure_chatbot::client_tool_calls::abort::refused_call_output;
23use crate::azure_chatbot::events::{StreamItem, TurnEvent};
24use crate::azure_chatbot::request::replayable_input_message;
25use crate::chatbot_error::ChatbotResult;
26use crate::chatbot_tools::{
27    ChatbotToolCallResult, call_chatbot_tool, check_client_tool_call, tool_is_answered_by_client,
28};
29use crate::citations::chatbot_cited_documents_to_citations;
30use crate::llm_utils::{APIInputMessage, APIOutputMessage, MessageContent};
31use crate::prelude::*;
32use crate::user_context::ChatbotTurnContext;
33
34/// How a round item that isn't a text `Message` or `FunctionCall` gets persisted, decided without
35/// a database connection or Azure configuration: a plain insert, or an insert followed by
36/// resolving the search result's cited documents.
37enum StoragePlan {
38    Insert(ChatbotConversationMessage),
39    InsertAndCite {
40        message: ChatbotConversationMessage,
41        document_urls: Vec<Url>,
42        response_id: String,
43    },
44}
45
46/// Routes an output item to how it should be persisted, or rejects it.
47///
48/// `Message`, `FunctionCall` and `FunctionCallOutput` are each handled by their caller before a
49/// call would reach here (a text `Message` is streamed, a refusal `Message` is inserted here, a
50/// `FunctionCall` is recorded by the round that receives it, and a `FunctionCallOutput` never
51/// arrives from Azure at all) — the error arms below are the guard against a caller passing one in
52/// anyway, on an unexpected wire shape, not a normal path.
53fn storage_plan(item: OutputItem, conversation_id: Uuid) -> ChatbotResult<StoragePlan> {
54    match item {
55        OutputItem::AzureAiSearchCall { .. } | OutputItem::Reasoning { .. } => {
56            let message = APIOutputMessage { message_type: item }
57                .to_chatbot_conversation_message(conversation_id)?;
58            Ok(StoragePlan::Insert(message))
59        }
60        OutputItem::AzureAiSearchCallOutput {
61            call_id,
62            output,
63            response_id,
64        } => {
65            // A search that failed or found nothing reports itself in the output text, which is
66            // stored and replayed to the model either way. Only its citations are lost.
67            let document_urls = match serde_json::from_str::<AISearchOutput>(&output) {
68                Ok(search_output) => search_output.get_urls,
69                Err(error) => {
70                    warn!("Storing an Azure AI Search output that carries no citations: {error}");
71                    Vec::new()
72                }
73            };
74            let message = APIOutputMessage {
75                message_type: OutputItem::AzureAiSearchCallOutput {
76                    call_id,
77                    output,
78                    response_id: response_id.clone(),
79                },
80            }
81            .to_chatbot_conversation_message(conversation_id)?;
82            if document_urls.is_empty() {
83                return Ok(StoragePlan::Insert(message));
84            }
85            Ok(StoragePlan::InsertAndCite {
86                message,
87                document_urls,
88                response_id,
89            })
90        }
91        OutputItem::Message {
92            content: content @ MessageContent::Refusal(..),
93            response_id,
94            role,
95        } => {
96            let message = APIOutputMessage {
97                message_type: OutputItem::Message {
98                    content,
99                    response_id,
100                    role,
101                },
102            }
103            .to_chatbot_conversation_message(conversation_id)?;
104            Ok(StoragePlan::Insert(message))
105        }
106        OutputItem::Message { .. } => Err(chatbot_err!(
107            UnexpectedProtocolShape,
108            "Unexpected message output item, it should have been streamed.".to_string()
109        )),
110        OutputItem::FunctionCall { .. } => Err(chatbot_err!(
111            UnexpectedProtocolShape,
112            "Unexpected function call output item, it should have been processed.".to_string()
113        )),
114        OutputItem::FunctionCallOutput { .. } => Err(chatbot_err!(
115            StreamInvariantViolation,
116            "Unexpected function call output item, this shouldn't happen.".to_string()
117        )),
118    }
119}
120
121/// Inserts an Azure AI Search output item, then best-effort resolves its cited documents.
122///
123/// A citation lookup failure is logged and swallowed rather than propagated: the search-output
124/// item is already stored correctly by the time citations run, and a citation is an annotation on
125/// it, not something worth ending the round over.
126async fn store_search_output_with_citations(
127    conn: &mut PgConnection,
128    message: ChatbotConversationMessage,
129    document_urls: Vec<Url>,
130    response_id: &str,
131    conversation_id: Uuid,
132    app_config: &ApplicationConfiguration,
133) -> ChatbotResult<ChatbotConversationMessage> {
134    let api_key = if let Some(azure_config) = &app_config.azure_configuration
135        && let Some(search_config) = &azure_config.search_config
136    {
137        &search_config.search_api_key
138    } else {
139        return Err(chatbot_err!(
140            Other,
141            "Azure search configuration not found, cannot process Azure AI search output item."
142                .to_string()
143        ));
144    };
145
146    let conversation_message = chatbot_conversation_messages::insert(conn, message).await?;
147
148    let res = chatbot_cited_documents_to_citations(
149        conn,
150        app_config.test_chatbot,
151        document_urls,
152        api_key,
153        conversation_message.id,
154        conversation_id,
155    )
156    .await;
157
158    if let Err(e) = res {
159        error!("Failed to save cited documents in the DB. Response id: {response_id} Error: {e}");
160    };
161
162    Ok(conversation_message)
163}
164
165/// Persists a round item that isn't a text `Message` or `FunctionCall`: `Reasoning` and
166/// `AzureAiSearchCall` insert as-is, a refusal `Message` inserts as-is, and an
167/// `AzureAiSearchCallOutput` additionally resolves its cited documents (see
168/// [`store_search_output_with_citations`] for why that half alone swallows its errors). Errors on
169/// a text `Message`, `FunctionCall` or `FunctionCallOutput` — see [`storage_plan`].
170pub(super) async fn store_output_item(
171    conn: &mut PgConnection,
172    item: OutputItem,
173    conversation_id: Uuid,
174    app_config: &ApplicationConfiguration,
175) -> ChatbotResult<ChatbotConversationMessage> {
176    match storage_plan(item, conversation_id)? {
177        StoragePlan::Insert(message) => {
178            Ok(chatbot_conversation_messages::insert(conn, message).await?)
179        }
180        StoragePlan::InsertAndCite {
181            message,
182            document_urls,
183            response_id,
184        } => {
185            store_search_output_with_citations(
186                conn,
187                message,
188                document_urls,
189                &response_id,
190                conversation_id,
191                app_config,
192            )
193            .await
194        }
195    }
196}
197
198/// Whether the round that produced `item` stores it itself, so that whoever else sees the item
199/// must not store it as well.
200///
201/// Exhaustive on purpose: an item kind that later needs round-owned storage has to be answered
202/// here rather than fall through a `matches!` somewhere and end up stored twice.
203pub(super) fn is_stored_by_round(item: &OutputItem) -> bool {
204    match item {
205        OutputItem::FunctionCall { .. } | OutputItem::FunctionCallOutput { .. } => true,
206        OutputItem::Message { .. }
207        | OutputItem::Reasoning { .. }
208        | OutputItem::AzureAiSearchCall { .. }
209        | OutputItem::AzureAiSearchCallOutput { .. } => false,
210    }
211}
212
213/// One item of a tool-call round, held until the round is known complete so it can be stored in
214/// its original stream order.
215///
216/// A round's function calls are only run, and inserted, once every item has streamed in; a
217/// `Passthrough` item stored as soon as it streams would then land ahead of all of them instead of
218/// next to the call it belongs beside. See [`TurnEvent::ItemAnnounced`].
219enum PendingRoundItem {
220    FunctionCall {
221        tool_name: String,
222        call_id: String,
223        arguments: String,
224    },
225    Passthrough(OutputItem),
226}
227
228/// What one tool-call round accumulates while it streams: the items it acts on once Azure has sent
229/// them all, the input the next round is sent, and whether the turn ends suspended instead of
230/// asking again.
231struct ToolRound {
232    pending_items: Vec<PendingRoundItem>,
233    next_round_input: Vec<APIInputMessage>,
234    /// The response every item of this round belongs to.
235    response_id: String,
236    suspended: bool,
237}
238
239impl ToolRound {
240    fn new(response_id: String) -> Self {
241        Self {
242            pending_items: Vec::new(),
243            next_round_input: Vec::new(),
244            response_id,
245            suspended: false,
246        }
247    }
248
249    /// Queues an item for the finalize pass.
250    ///
251    /// Only an item Azure has sent whole may be queued: it sends each one twice, and the earlier
252    /// copy has neither a call's arguments nor a reasoning item's payload, both of which the next
253    /// request has to carry. Errors on the item kinds the round handles before it reaches here: a
254    /// `Message` is either streamed or, as a refusal, stored as it arrives, and a
255    /// `FunctionCallOutput` never arrives from Azure at all.
256    fn queue(&mut self, item: OutputItem) -> ChatbotResult<()> {
257        let pending = match item {
258            OutputItem::FunctionCall {
259                tool_name,
260                call_id,
261                arguments,
262                ..
263            } => PendingRoundItem::FunctionCall {
264                tool_name,
265                call_id,
266                arguments,
267            },
268            OutputItem::Reasoning { .. }
269            | OutputItem::AzureAiSearchCall { .. }
270            | OutputItem::AzureAiSearchCallOutput { .. } => PendingRoundItem::Passthrough(item),
271            OutputItem::Message { .. } | OutputItem::FunctionCallOutput { .. } => {
272                return Err(chatbot_err!(
273                    UnexpectedProtocolShape,
274                    "Unexpected output item queued for the round's finalize pass.".to_string()
275                ));
276            }
277        };
278        self.pending_items.push(pending);
279        Ok(())
280    }
281
282    fn has_function_calls(&self) -> bool {
283        self.pending_items
284            .iter()
285            .any(|item| matches!(item, PendingRoundItem::FunctionCall { .. }))
286    }
287}
288
289/// What the round does with one call the model made.
290enum PlannedToolCall {
291    /// Only the client can answer it: record the call without an output and end the turn.
292    Suspend,
293    /// The server answers it, in this round.
294    Run,
295    /// A client call the tool would not accept, carrying the output the LLM is given for it.
296    Refuse(String),
297}
298
299/// Decides who answers a call the model made, before anything about it is stored.
300///
301/// A client tool's authorization and arguments are both checked here, not when an answer arrives:
302/// nothing can answer a call the tool would reject or the caller may not make, so it has to fail
303/// while the turn can still hand the LLM a failure output. The server tools are checked the same
304/// way inside [`call_chatbot_tool`]. Errors on a rejection the turn cannot survive — see
305/// [`recover_or_terminate`].
306async fn plan_tool_call(
307    conn: &mut PgConnection,
308    user_context: &ChatbotTurnContext,
309    tool_name: &str,
310    arguments: &str,
311) -> ChatbotResult<PlannedToolCall> {
312    if !tool_is_answered_by_client(tool_name) {
313        return Ok(PlannedToolCall::Run);
314    }
315    match check_client_tool_call(conn, user_context, tool_name, arguments).await {
316        Ok(Ok(())) => Ok(PlannedToolCall::Suspend),
317        Ok(Err(refusal)) => Ok(PlannedToolCall::Refuse(
318            refused_call_output(refusal, tool_name).to_string(),
319        )),
320        Err(error) => Ok(PlannedToolCall::Refuse(recover_or_terminate(
321            error,
322            tool_name,
323            "A client chatbot tool call was refused before the turn could suspend on it, reporting the failure to the LLM.",
324        )?)),
325    }
326}
327
328/// What is stored for a call whose real output could not be written. Short by construction: an
329/// output too large for its column is the way that write fails.
330const UNSTORABLE_OUTPUT_PLACEHOLDER: &str = "The tool ran, but its result could not be stored and \
331is no longer available. Tell the user the lookup did not come back, or try again with a narrower \
332call.";
333
334/// [record_tool_call], falling back to a placeholder output when the real one cannot be written.
335///
336/// Both rows die with the transaction on a failed write, so without this the turn ends and the log
337/// cannot even name the tool that killed it -- the call row was never committed. Storing a short
338/// output instead keeps the conversation answerable and leaves the model something to say.
339async fn record_tool_call_with_fallback(
340    conn: &mut PgConnection,
341    conversation_id: Uuid,
342    response_id: &str,
343    call_id: &str,
344    tool_name: &str,
345    result: ChatbotToolCallResult,
346) -> ChatbotResult<Vec<APIInputMessage>> {
347    let arguments = result.arguments.clone();
348    let output_bytes = result.output.len();
349    let error = match record_tool_call(
350        conn,
351        conversation_id,
352        response_id,
353        call_id,
354        tool_name,
355        result,
356    )
357    .await
358    {
359        Ok(recorded) => return Ok(recorded),
360        Err(error) => error,
361    };
362    error!(
363        "Could not store the output of {tool_name} ({output_bytes} bytes). Storing a placeholder output instead. Error: {error:?}"
364    );
365    record_tool_call(
366        conn,
367        conversation_id,
368        response_id,
369        call_id,
370        tool_name,
371        ChatbotToolCallResult {
372            arguments,
373            output: UNSTORABLE_OUTPUT_PLACEHOLDER.to_string(),
374            citations: Vec::new(),
375        },
376    )
377    .await
378}
379
380/// Stores a finished tool call beside its output, and converts both rows back into the items the
381/// next round is sent.
382///
383/// The two rows go in one transaction: a call stored without its output is exactly the history the
384/// unanswered-call sweep exists to repair, and the LLM rejects the conversation until it is.
385async fn record_tool_call(
386    conn: &mut PgConnection,
387    conversation_id: Uuid,
388    response_id: &str,
389    call_id: &str,
390    tool_name: &str,
391    result: ChatbotToolCallResult,
392) -> ChatbotResult<Vec<APIInputMessage>> {
393    let citations = result.citations;
394    let tool_call_message = APIOutputMessage {
395        message_type: OutputItem::FunctionCall {
396            response_id: response_id.to_owned(),
397            call_id: call_id.to_owned(),
398            tool_name: tool_name.to_owned(),
399            arguments: result.arguments,
400        },
401    };
402    let output_message = APIOutputMessage {
403        message_type: OutputItem::FunctionCallOutput {
404            call_id: call_id.to_owned(),
405            output: result.output,
406            response_id: response_id.to_owned(),
407        },
408    };
409
410    let mut tx = conn.begin().await?;
411    let stored_call = chatbot_conversation_messages::insert(
412        &mut tx,
413        tool_call_message.to_chatbot_conversation_message(conversation_id)?,
414    )
415    .await?;
416    let stored_output = chatbot_conversation_messages::insert(
417        &mut tx,
418        output_message.to_chatbot_conversation_message(conversation_id)?,
419    )
420    .await?;
421
422    if !citations.is_empty() {
423        let (rows, page_ids) = citations
424            .into_iter()
425            .map(|citation| {
426                (
427                    ChatbotConversationMessageCitation {
428                        conversation_message_id: stored_output.id,
429                        conversation_id,
430                        title: citation.title,
431                        content: citation.snippet,
432                        document_url: citation.document_url,
433                        citation_number: citation.citation_number,
434                        ..Default::default()
435                    },
436                    Some(citation.page_id),
437                )
438            })
439            .unzip();
440        chatbot_conversation_messages_citations::insert_batch(&mut tx, rows, page_ids).await?;
441    }
442
443    tx.commit().await?;
444
445    Ok(vec![
446        APIInputMessage::try_from(stored_call)?,
447        APIInputMessage::try_from(stored_output)?,
448    ])
449}
450
451/// The item with a reasoning payload left out, for an event that only names the item.
452///
453/// A reasoning `encrypted_content` is multi-KB base64, and a deferred item is stored by the round
454/// that produced it rather than from the event, which nothing downstream reads more than the id of.
455fn item_without_reasoning_payload(item: &OutputItem) -> OutputItem {
456    match item {
457        OutputItem::Reasoning {
458            response_id, id, ..
459        } => OutputItem::Reasoning {
460            response_id: response_id.clone(),
461            id: id.clone(),
462            summary: Vec::new(),
463            encrypted_content: None,
464        },
465        other => other.clone(),
466    }
467}
468
469/// Streams and parses one tool-call round of a response from Azure, consuming `lines`.
470///
471/// Runs the calls the server answers, stores each call beside its output, and ends the round by
472/// yielding [`TurnEvent::Messages`] with the items the next round is sent. Those items are
473/// converted from the rows this round wrote, not from what it meant to write, so that a round
474/// continued in memory and one replayed from the conversation hand Azure the same prefix. A call
475/// only the client can answer is stored without an output and ends the turn with
476/// [`TurnEvent::Suspended`] instead: the answer arrives in a later request, which rebuilds its
477/// input from the conversation.
478///
479/// `calls_from_classification` are this round's function calls that arrived before the response
480/// was classified. They have already been streamed to the client; the round takes them over so
481/// that it, and only it, records them.
482///
483/// Takes `conn` by value rather than by reference so that the caller can hand over the pooled
484/// connection it no longer needs, instead of keeping one borrowed for as long as this stream lives.
485pub(super) async fn parse_tool<'a, C>(
486    mut conn: C,
487    app_config: &'a ApplicationConfiguration,
488    mut lines: ResponseLinesStream<'a>,
489    conversation_id: Uuid,
490    response_id: String,
491    user_context: &'a ChatbotTurnContext,
492    calls_from_classification: Vec<OutputItem>,
493) -> BoxStream<'a, ChatbotResult<TurnEvent>>
494where
495    C: DerefMut<Target = PgConnection> + Send + 'a,
496{
497    let mut round = ToolRound::new(response_id);
498    let mut response_received = false;
499    let mut response_incomplete = false;
500    let mut preceding_event: Option<AzureStreamEvent> = None;
501
502    trace!("Parsing tool calls...");
503
504    Box::pin(async_stream::try_stream! {
505    for call in calls_from_classification {
506        round.queue(call)?;
507    }
508    while let Some(val) = lines.next().await {
509        let line = val?;
510        let response_output: ResponseOutput = match ParsedResponseLine::parse(&line)? {
511            Some(ParsedResponseLine::Event(event)) => {
512                trace!("Event: {event:?}");
513                match &event {
514                    AzureStreamEvent::ResponseCompleted => {
515                        response_received = true;
516                    }
517                    AzureStreamEvent::Incomplete => {
518                        response_received = true;
519                        response_incomplete = true;
520                    }
521                    AzureStreamEvent::OutputTextDelta => {
522                        Err(chatbot_err!(UnexpectedProtocolShape,
523                            "Error: Received response text while parsing tool calls. Either the tool call parsing failed or the LLM responded in an unexpected way."
524                        ))?
525                    }
526                    AzureStreamEvent::ErrorReported => {
527                        // error is logged in the next iteration
528                     }
529                    _ => {}
530                };
531                preceding_event = Some(event);
532                continue;
533            }
534            Some(ParsedResponseLine::Data(data)) => *data,
535            None => {
536                continue;
537            }
538        };
539
540        let event = AzureStreamEvent::of_data_line(preceding_event.take(), response_output.response_type.as_deref());
541
542        check_response_output(&response_output, Some(&round.response_id), "streaming_tool_call_round")?;
543
544        if response_received {
545            // A round cut short carries calls whose arguments may be truncated, so it must not
546            // go on to run them.
547            check_response_complete(&response_output, response_incomplete)?;
548            if !round.has_function_calls() {
549                Err(chatbot_err!(StreamInvariantViolation,
550                    "The LLM response was supposed to contain function calls, but no function calls were found"
551                ))?
552            }
553            let response_id = round.response_id.clone();
554
555            for pending_item in std::mem::take(&mut round.pending_items) {
556                let (name, id, args) = match pending_item {
557                    PendingRoundItem::FunctionCall { tool_name, call_id, arguments } => {
558                        (tool_name, call_id, arguments)
559                    }
560                    // Stored here, in the round's original stream order alongside the function
561                    // calls, rather than as soon as it streamed in: see
562                    // [`TurnEvent::ItemAnnounced`].
563                    PendingRoundItem::Passthrough(item) => {
564                        let stored = store_output_item(&mut conn, item, conversation_id, app_config).await?;
565                        if let Some(input) = replayable_input_message(stored)? {
566                            round.next_round_input.push(input);
567                        }
568                        continue;
569                    }
570                };
571                let refused_client_call = match plan_tool_call(&mut conn, user_context, &name, &args).await? {
572                    PlannedToolCall::Suspend => {
573                        // Recorded without an output: the client answers it through the
574                        // tool-response endpoint, which resumes the turn from the conversation
575                        // as stored, so the call has to be in the conversation before the turn
576                        // ends.
577                        let tool_call_message = APIOutputMessage {
578                            message_type: OutputItem::FunctionCall {
579                                response_id: response_id.clone(),
580                                call_id: id,
581                                tool_name: name,
582                                arguments: args,
583                            },
584                        };
585                        chatbot_conversation_messages::insert(
586                            &mut conn,
587                            tool_call_message.to_chatbot_conversation_message(conversation_id)?,
588                        )
589                        .await?;
590                        round.suspended = true;
591                        continue;
592                    }
593                    PlannedToolCall::Refuse(output) => Some(output),
594                    PlannedToolCall::Run => None,
595                };
596
597                let tool_result = if let Some(output) = refused_client_call {
598                    ChatbotToolCallResult {
599                        arguments: args,
600                        output,
601                        citations: Vec::new(),
602                    }
603                } else {
604                    // The tool runs outside the transaction so a failure cannot leave a
605                    // function call without its output. `args` is only borrowed here, so it is
606                    // still available below on the error path.
607                    let tool_call =
608                        call_chatbot_tool(&mut conn, app_config, &name, &args, user_context).await;
609                    match tool_call {
610                        Ok(result) => result,
611                        Err(error) => ChatbotToolCallResult {
612                            output: recover_or_terminate(
613                                error,
614                                &name,
615                                "Chatbot tool call failed, reporting the failure to the LLM.",
616                            )?,
617                            arguments: args,
618                            citations: Vec::new(),
619                        },
620                    }
621                };
622
623                let recorded = record_tool_call_with_fallback(
624                    &mut conn,
625                    conversation_id,
626                    &response_id,
627                    &id,
628                    &name,
629                    tool_result,
630                )
631                .await?;
632                round.next_round_input.extend(recorded);
633
634                yield TurnEvent::Item(StreamItem::ServerToolOutput { call_id: id });
635            }
636
637            if round.suspended {
638                // No further round: the answers the turn is missing arrive in later requests, and
639                // the resumed turn rebuilds its input from the conversation rather than from here.
640                yield TurnEvent::Suspended;
641            } else {
642                yield TurnEvent::Messages(std::mem::take(&mut round.next_round_input));
643            }
644            return;
645        } else if let Some(item) = response_output.item.and_then(ReceivedOutputItem::known) {
646            let finished = matches!(event, Some(AzureStreamEvent::OutputItemDone));
647            match &item {
648                OutputItem::FunctionCall { tool_name, call_id, arguments, .. } => {
649                    // The first call of a round loses its `added` copy to the stream type
650                    // detection, so a round that queued both copies would record every later call
651                    // twice, once with no arguments at all.
652                    if finished {
653                        round.pending_items.push(PendingRoundItem::FunctionCall {
654                            tool_name: tool_name.clone(),
655                            call_id: call_id.clone(),
656                            arguments: arguments.clone(),
657                        });
658                    }
659                    yield TurnEvent::Item(StreamItem::Received { item, finished: false });
660                }
661                // Azure's `added` copy of a message has no content yet, and an empty content
662                // reads as text rather than as the refusal the done copy will carry.
663                OutputItem::Message { .. } if !finished => {}
664                OutputItem::Message { content, .. } => {
665                    if let MessageContent::Refusal(..) = content {
666                        // Stored as it arrives, ahead of the round's deferred items, so that its
667                        // place in the conversation is the one the next round's input gives it.
668                        let stored = store_output_item(&mut conn, item, conversation_id, app_config).await?;
669                        let message_id = stored.id;
670                        let text = match &stored.message {
671                            chatbot_conversation_messages::Message::Text(text_message) => {
672                                text_message.text.clone()
673                            }
674                            other => Err(chatbot_err!(
675                                StreamInvariantViolation,
676                                format!("A stored refusal message came back as {other:?}.")
677                            ))?,
678                        };
679                        round.next_round_input.push(APIInputMessage::try_from(stored)?);
680                        yield TurnEvent::Refusal { text, message_id };
681                    } else {
682                    Err(chatbot_err!(
683                        UnexpectedProtocolShape,
684                        "Received a message item while parsing tool calls.".to_string()
685                    ))?}
686                },
687                _ => {
688                    // Storage is deferred to the round's finalize pass (see
689                    // `PendingRoundItem::Passthrough` above), which is what keeps this item at its
690                    // stream position relative to the round's function calls instead of landing
691                    // ahead of all of them.
692                    if finished {
693                        yield TurnEvent::ItemAnnounced(item_without_reasoning_payload(&item));
694                        round.queue(item)?;
695                    } else {
696                        yield TurnEvent::Item(StreamItem::Received { item, finished });
697                    }
698                }
699            }
700        }
701    }
702    // Reached only when Azure stopped sending before it completed the response. Without it the
703    // round yields nothing and the turn silently asks again, with a call in its input that has no
704    // output after it.
705    Err(chatbot_err!(StreamEndedEarly, "Stream ended unexpectedly"))?;
706    })
707}
708
709/// Decides whether a tool-call error ends the turn or is reported to the LLM as a failed call.
710///
711/// `context` opens the warning logged for the non-terminating case; the caller still owns whether
712/// the recovered text is wrapped as a suspended call's answer or a served call's output.
713fn recover_or_terminate(
714    error: ChatbotError,
715    tool_name: &str,
716    context: &str,
717) -> ChatbotResult<String> {
718    if error.error_type().should_terminate_stream() {
719        return Err(error);
720    }
721    warn!("{context} Tool: {tool_name}. Error: {error:?}");
722    Ok(tool_failure_output_for_llm(&error))
723}
724
725/// Turn a failed tool call into a function call output the LLM can act on, so it can
726/// recover or explain the failure to the user instead of the turn dying.
727///
728/// Only messages written in tool code are passed through; anything else is reported
729/// generically, because other messages are built from library errors and can carry
730/// internals such as SQL or endpoint URLs.
731fn tool_failure_output_for_llm(error: &ChatbotError) -> String {
732    let reason = match error.error_type() {
733        ChatbotErrorType::InvalidToolName
734        | ChatbotErrorType::InvalidToolArguments
735        | ChatbotErrorType::ToolUseError => error.message(),
736        _ => "The tool is unavailable.",
737    };
738    format!(
739        "The tool call failed and returned no data. Reason: {reason} Answer the user without this tool, or tell them what you would need to answer."
740    )
741}
742
743#[cfg(test)]
744mod tests {
745    use headless_lms_models::{
746        insert_data,
747        test_helper::{Conn, insert_chatbot_conversation},
748    };
749
750    use super::*;
751    use crate::azure_chatbot::azure::protocol::InputItem;
752    use crate::azure_chatbot::test_helpers::{azure_response_stream, shape};
753    use crate::chatbot_tools::tool_authorization::test_helpers::context;
754
755    /// Azure sends an item as `added` before it sends it as `done`, and only the `done` copy is
756    /// whole or stored. Carrying both into the next round would send the item twice, and with
757    /// `store` off the `added` copy of a reasoning item has no `encrypted_content`, which Azure
758    /// rejects outright.
759    #[tokio::test]
760    async fn only_the_finished_copy_of_a_streamed_item_reaches_the_next_round() {
761        insert_data!(:tx);
762        let (_configuration, conversation_id) = insert_chatbot_conversation(tx.as_mut()).await;
763        let user_context = context(None, None, Vec::new());
764        let app_config =
765            ApplicationConfiguration::mock_conf().expect("the mock configuration builds");
766
767        let mut events = parse_tool(
768            tx.as_mut() as &mut PgConnection,
769            &app_config,
770            azure_response_stream(&[
771                "event: response.output_item.added",
772                r#"data: {"type":"response.output_item.added","item":{"type":"reasoning","id":"rs_1","response_id":"resp_1","summary":[]}}"#,
773                "event: response.output_item.done",
774                r#"data: {"type":"response.output_item.done","item":{"type":"reasoning","id":"rs_1","response_id":"resp_1","summary":[],"encrypted_content":"payload"}}"#,
775                "event: response.output_item.done",
776                r#"data: {"type":"response.output_item.done","item":{"type":"function_call","id":"fc_1","response_id":"resp_1","call_id":"call_1","name":"no_such_tool","arguments":"{}"}}"#,
777                "event: response.completed",
778                r#"data: {"type":"response.completed","response":{"id":"resp_1"}}"#,
779            ]),
780            conversation_id,
781            "resp_1".to_string(),
782            &user_context,
783            Vec::new(),
784        )
785        .await;
786
787        let mut next_round = None;
788        while let Some(event) = events.next().await {
789            if let TurnEvent::Messages(messages) = event.expect("the round streams to the end") {
790                next_round = Some(messages);
791            }
792        }
793
794        let next_round = next_round.expect("the round hands its items on");
795        assert_eq!(
796            shape(&next_round),
797            vec!["reasoning:rs_1", "call:call_1", "output:call_1"],
798        );
799        let InputItem::Reasoning {
800            encrypted_content, ..
801        } = &next_round[0].message_type
802        else {
803            panic!("the first item is the reasoning item");
804        };
805        assert_eq!(encrypted_content.as_deref(), Some("payload"));
806    }
807
808    /// A search that failed or found nothing reports itself as plain text rather than the
809    /// `AISearchOutput` JSON shape, and that text must still be stored, with just its citations
810    /// skipped, rather than aborting the round.
811    #[test]
812    fn a_non_conforming_search_output_is_stored_without_citations() {
813        let item = OutputItem::AzureAiSearchCallOutput {
814            response_id: "resp_1".to_string(),
815            call_id: "call_1".to_string(),
816            output: "remote tool call failed".to_string(),
817        };
818
819        let plan =
820            storage_plan(item, Uuid::new_v4()).expect("a non-conforming output still stores");
821        assert!(matches!(plan, StoragePlan::Insert(_)));
822    }
823
824    /// A proxy that closes the body cleanly before `response.completed` must not let the round
825    /// loop silently with a call in its input that has no output after it.
826    #[tokio::test]
827    async fn a_stream_that_ends_before_response_completed_errors() {
828        insert_data!(:tx);
829        let (_configuration, conversation_id) = insert_chatbot_conversation(tx.as_mut()).await;
830        let user_context = context(None, None, Vec::new());
831        let app_config =
832            ApplicationConfiguration::mock_conf().expect("the mock configuration builds");
833
834        let mut events = parse_tool(
835            tx.as_mut() as &mut PgConnection,
836            &app_config,
837            azure_response_stream(&[
838                "event: response.output_item.done",
839                r#"data: {"type":"response.output_item.done","item":{"type":"function_call","id":"fc_1","response_id":"resp_1","call_id":"call_1","name":"no_such_tool","arguments":"{}"}}"#,
840            ]),
841            conversation_id,
842            "resp_1".to_string(),
843            &user_context,
844            Vec::new(),
845        )
846        .await;
847
848        let error = loop {
849            match events.next().await.expect("the stream ends in an error") {
850                Ok(_) => continue,
851                Err(error) => break error,
852            }
853        };
854        assert_eq!(*error.error_type(), ChatbotErrorType::StreamEndedEarly);
855    }
856}