Skip to main content

headless_lms_chatbot/azure_chatbot/client_tool_calls/
repair.rs

1//! Answering tool calls that a dead turn left without an output, which the LLM rejects for every
2//! later message of the conversation.
3
4use headless_lms_models::chatbot_conversation_message_tool_calls::ChatbotConversationMessageToolCall;
5use headless_lms_models::chatbot_conversation_messages::UnansweredToolCallScope;
6use tracing::trace;
7
8use super::abort::ToolCallAbortReason;
9use crate::chatbot_error::ChatbotResult;
10use crate::prelude::*;
11
12/// Repairs the tool calls that the turn this request is itself running left unanswered, whatever
13/// their age. `response_ids` names the responses that turn's rounds were given, which is what keeps
14/// it off the calls of a turn streaming in another request. Use
15/// [answer_stale_unfinished_tool_calls] from a request that made none of them.
16pub(crate) async fn answer_unfinished_tool_calls(
17    conn: &mut PgConnection,
18    conversation_id: Uuid,
19    response_ids: &[String],
20) -> ChatbotResult<()> {
21    if response_ids.is_empty() {
22        return Ok(());
23    }
24    reap_unanswered_tool_calls(
25        conn,
26        conversation_id,
27        UnansweredToolCallScope::OwnTurn(response_ids),
28    )
29    .await?;
30    Ok(())
31}
32
33/// How long a tool call must have gone unanswered before a request that did not make it may abort
34/// it. A live turn writes a call and its output seconds apart, but a slow search or a reasoning
35/// round widens that gap, and aborting inside it leaves two outputs for one `tool_call_id`.
36const HANGING_TOOL_CALL_REAP_AFTER_MINUTES: i64 = 10;
37
38/// Repairs tool calls left behind by turns that are long dead, without touching one that a turn
39/// streaming in a concurrent request may still be about to answer. Returns the calls it left
40/// unanswered.
41pub(crate) async fn answer_stale_unfinished_tool_calls(
42    conn: &mut PgConnection,
43    conversation_id: Uuid,
44) -> ChatbotResult<Vec<ChatbotConversationMessageToolCall>> {
45    let cutoff = Utc::now() - chrono::Duration::minutes(HANGING_TOOL_CALL_REAP_AFTER_MINUTES);
46    reap_unanswered_tool_calls(
47        conn,
48        conversation_id,
49        UnansweredToolCallScope::AnyTurnOlderThan(cutoff),
50    )
51    .await
52}
53
54/// Aborts the conversation's tool calls that have no output and fall within `scope`. Returns the
55/// calls it left unanswered.
56async fn reap_unanswered_tool_calls(
57    conn: &mut PgConnection,
58    conversation_id: Uuid,
59    scope: UnansweredToolCallScope<'_>,
60) -> ChatbotResult<Vec<ChatbotConversationMessageToolCall>> {
61    trace!(
62        "Dealing with unfinished tool calls for conversation {}",
63        conversation_id
64    );
65    headless_lms_models::chatbot_conversation_messages::answer_hanging_tool_call_messages_for_conversation(
66        conn,
67        conversation_id,
68        scope,
69        ToolCallAbortReason::Hanging.model_output(),
70    )
71    .await
72    .map_err(ChatbotError::from)
73}