Skip to main content

headless_lms_chatbot/azure_chatbot/client_tool_calls/
answer.rs

1//! Turning a client's answer to a suspended tool call into the output that resumes the turn.
2
3use headless_lms_base::config::ApplicationConfiguration;
4use headless_lms_models::chatbot_conversation_message_tool_calls::{
5    self, ChatbotConversationMessageToolCall,
6};
7
8use super::abort::refused_call_output;
9use crate::chatbot_error::ChatbotResult;
10use crate::chatbot_tools::{
11    ClientToolAnswer, check_client_tool_call, client_tool_answer_output, execute_action_tool,
12    tool_is_answered_by_client, tool_is_confirmable_action,
13};
14use crate::prelude::*;
15use crate::user_context::ChatbotTurnContext;
16
17/// What a client's answer to a suspended call amounts to once it is applied.
18pub(crate) struct AnsweredClientToolCall {
19    /// The tool output the resumed turn reads.
20    pub(crate) output: String,
21    /// The payload the client sent, or None when the call was aborted instead of answered.
22    pub(crate) client_answer: Option<serde_json::Value>,
23    /// Data for the confirming admin's browser only, from a confirmed action tool's execution.
24    /// Never persisted; carried out of band as an `ActionExecuted` stream event.
25    pub(crate) execution_payload: Option<serde_json::Value>,
26}
27
28/// Turns a client's answer to a suspended call into the tool output that resumes the turn.
29///
30/// `unanswered` are the conversation's calls that have no output, which is where the answered call
31/// has to be found: an answered call carries a failure output written at stream time, and building
32/// an output for it re-parses arguments that were already refused, failing the request as a server
33/// fault instead of telling the client the call is closed. `answer_client_tool_call` decides the
34/// same thing again under the conversation lock; this only decides which error the client sees.
35///
36/// Fails with [ChatbotErrorType::InvalidToolAnswer] when `conversation_id` has no such call, the
37/// call has already been answered, or the call is not one a client answers, all of which the
38/// client got wrong.
39pub(crate) async fn client_tool_output_for_answer(
40    conn: &mut PgConnection,
41    app_config: &ApplicationConfiguration,
42    conversation_id: Uuid,
43    unanswered: &[ChatbotConversationMessageToolCall],
44    tool_call_id: &str,
45    answer: &ClientToolAnswer,
46    user_context: &ChatbotTurnContext,
47) -> ChatbotResult<AnsweredClientToolCall> {
48    let Some(tool_call) = unanswered
49        .iter()
50        .find(|call| call.tool_call_id == tool_call_id)
51    else {
52        return Err(missing_tool_call_error(conn, conversation_id, tool_call_id).await?);
53    };
54
55    if !tool_is_answered_by_client(&tool_call.tool_name) {
56        return Err(chatbot_err!(
57            InvalidToolAnswer,
58            format!("Tool call {tool_call_id} is not one a client answers")
59        ));
60    }
61
62    apply_client_tool_answer(conn, app_config, tool_call, answer, user_context).await
63}
64
65/// Why a call the client wants to answer is not among the conversation's unanswered ones: it
66/// already has an output, or the conversation never had it.
67async fn missing_tool_call_error(
68    conn: &mut PgConnection,
69    conversation_id: Uuid,
70    tool_call_id: &str,
71) -> ChatbotResult<ChatbotError> {
72    let exists =
73        models::chatbot_conversation_message_tool_calls::get_by_conversation_and_tool_call_id(
74            conn,
75            conversation_id,
76            tool_call_id,
77        )
78        .await?
79        .is_some();
80    Ok(if exists {
81        chatbot_err!(
82            InvalidToolAnswer,
83            format!("Tool call {tool_call_id} has already been answered")
84        )
85    } else {
86        chatbot_err!(
87            InvalidToolAnswer,
88            format!("Chatbot conversation {conversation_id} has no tool call {tool_call_id}")
89        )
90    })
91}
92
93/// The tool output a client's answer to `tool_call` amounts to.
94///
95/// The call is authorized again here rather than trusted from the moment it was offered: nothing
96/// bounds how long a call waits, so a role or the configuration can change while it does. A call
97/// the caller may no longer make is aborted with an explanation for the model instead of having
98/// its answer applied, because the turn stays stuck until its call has some output.
99async fn apply_client_tool_answer(
100    conn: &mut PgConnection,
101    app_config: &ApplicationConfiguration,
102    tool_call: &ChatbotConversationMessageToolCall,
103    answer: &ClientToolAnswer,
104    user_context: &ChatbotTurnContext,
105) -> ChatbotResult<AnsweredClientToolCall> {
106    if let Err(refusal) = check_client_tool_call(
107        conn,
108        user_context,
109        &tool_call.tool_name,
110        &tool_call.arguments_json(),
111    )
112    .await?
113    {
114        return Ok(AnsweredClientToolCall {
115            output: refused_call_output(refusal, &tool_call.tool_name).to_string(),
116            client_answer: None,
117            execution_payload: None,
118        });
119    }
120
121    if tool_is_confirmable_action(&tool_call.tool_name) {
122        // Exactly-once guard: locks the call row for the rest of this transaction and refuses if
123        // it was already answered, so two concurrent confirms cannot both execute the mutation.
124        chatbot_conversation_message_tool_calls::lock_unanswered_for_execution(conn, tool_call.id)
125            .await?;
126
127        let outcome =
128            execute_action_tool(conn, app_config, tool_call, answer, user_context).await?;
129        let ClientToolAnswer::Data { result } = answer;
130        return Ok(AnsweredClientToolCall {
131            output: outcome.output,
132            client_answer: Some(result.clone()),
133            execution_payload: outcome.client_payload,
134        });
135    }
136
137    let output =
138        client_tool_answer_output(&tool_call.tool_name, &tool_call.arguments_json(), answer)?;
139    let ClientToolAnswer::Data { result } = answer;
140    Ok(AnsweredClientToolCall {
141        output,
142        client_answer: Some(result.clone()),
143        execution_payload: None,
144    })
145}
146
147/// An answer the conversation has no room for is the client's mistake and has to reach it as a
148/// client error instead of as a failed turn. Everything else stays a server fault.
149pub(crate) fn rejected_tool_answer_error(error: ModelError) -> ChatbotError {
150    match error.error_type() {
151        ModelErrorType::RecordNotFound | ModelErrorType::InvalidRequest => {
152            let message = error.message().to_string();
153            chatbot_err!(InvalidToolAnswer, message, error)
154        }
155        _ => ChatbotError::from(error),
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use headless_lms_models::{
162        chatbot_conversation_message_tool_calls::ToolKind,
163        insert_data,
164        test_helper::{Conn, init_app_conf},
165    };
166
167    use super::*;
168    use crate::azure_chatbot::client_tool_calls::abort::ToolCallAbortReason;
169    use crate::chatbot_tools::{
170        ChatbotToolDeclaration,
171        client_tools::ask_multiple_choice_question::AskMultipleChoiceQuestionTool,
172        tool_authorization::test_helpers::{context, context_with_categories},
173    };
174
175    /// A recorded call to the multiple choice tool, as the suspending turn wrote it: with the
176    /// argument text the model produced rather than with the object it parses into.
177    fn recorded_question() -> ChatbotConversationMessageToolCall {
178        ChatbotConversationMessageToolCall {
179            tool_name: <AskMultipleChoiceQuestionTool as ChatbotToolDeclaration>::NAME.to_string(),
180            tool_arguments: serde_json::Value::String(
181                r#"{"question":"Which loop?","choices":["while","for"]}"#.to_string(),
182            ),
183            tool_call_id: "call_1".to_string(),
184            tool_kind: ToolKind::ClientTool,
185            response_id: "resp_1".to_string(),
186            ..Default::default()
187        }
188    }
189
190    fn picked_the_second_choice() -> ClientToolAnswer {
191        ClientToolAnswer::Data {
192            result: serde_json::json!({ "choice_index": 1 }),
193        }
194    }
195
196    /// The abort branch: a call whose category the configuration no longer offers is closed with
197    /// an explanation for the model instead of having its answer applied. The only registered
198    /// client tool requires nothing of its caller, so the category is what this can vary.
199    #[tokio::test]
200    async fn apply_client_tool_answer_aborts_a_call_the_caller_can_no_longer_make() {
201        insert_data!(:tx, :user, :org, :course);
202        let no_categories = context_with_categories(Some(user), Some(course), Vec::new(), &[]);
203        let app_config = init_app_conf().expect("Application Configuration initialization failed");
204
205        let aborted = apply_client_tool_answer(
206            tx.as_mut(),
207            &app_config,
208            &recorded_question(),
209            &picked_the_second_choice(),
210            &no_categories,
211        )
212        .await
213        .expect("the call is aborted rather than failed");
214
215        assert_eq!(
216            aborted.output,
217            ToolCallAbortReason::CategoryDisabled.model_output()
218        );
219        assert_eq!(aborted.client_answer, None);
220    }
221
222    #[tokio::test]
223    async fn an_answer_from_a_caller_who_may_still_make_the_call_is_applied() {
224        insert_data!(:tx, :user, :org, :course);
225        let learner = context(Some(user), Some(course), Vec::new());
226        let app_config = init_app_conf().expect("Application Configuration initialization failed");
227
228        let applied = apply_client_tool_answer(
229            tx.as_mut(),
230            &app_config,
231            &recorded_question(),
232            &picked_the_second_choice(),
233            &learner,
234        )
235        .await
236        .expect("the answer is applied");
237
238        assert!(applied.output.contains("\"for\""), "{}", applied.output);
239        assert_eq!(
240            applied.client_answer,
241            Some(serde_json::json!({ "choice_index": 1 }))
242        );
243    }
244}