1use 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
34enum StoragePlan {
38 Insert(ChatbotConversationMessage),
39 InsertAndCite {
40 message: ChatbotConversationMessage,
41 document_urls: Vec<Url>,
42 response_id: String,
43 },
44}
45
46fn 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 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
121async 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
165pub(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
198pub(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
213enum PendingRoundItem {
220 FunctionCall {
221 tool_name: String,
222 call_id: String,
223 arguments: String,
224 },
225 Passthrough(OutputItem),
226}
227
228struct ToolRound {
232 pending_items: Vec<PendingRoundItem>,
233 next_round_input: Vec<APIInputMessage>,
234 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 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
289enum PlannedToolCall {
291 Suspend,
293 Run,
295 Refuse(String),
297}
298
299async 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
328const 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
334async 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
380async 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
451fn 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
469pub(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 }
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 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 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 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 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 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 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 OutputItem::Message { .. } if !finished => {}
664 OutputItem::Message { content, .. } => {
665 if let MessageContent::Refusal(..) = content {
666 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 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 Err(chatbot_err!(StreamEndedEarly, "Stream ended unexpectedly"))?;
706 })
707}
708
709fn 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
725fn 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 #[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 #[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 #[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}