headless_lms_chatbot/azure_chatbot/client_tool_calls/
answer.rs1use 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
17pub(crate) struct AnsweredClientToolCall {
19 pub(crate) output: String,
21 pub(crate) client_answer: Option<serde_json::Value>,
23 pub(crate) execution_payload: Option<serde_json::Value>,
26}
27
28pub(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
65async 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
93async 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 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
147pub(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 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 #[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}