1use crate::{
2 azure_chatbot::azure::tools::{AzureLLMFunctionToolDefinition, AzureLLMToolDefinition},
3 chatbot_tools::{
4 action_tools::{
5 ConfirmAnswer, ConfirmableActionTool, edit_user_account::EditUserAccountTool,
6 generate_password_reset_link::GeneratePasswordResetLinkTool,
7 reset_exercises::ResetExercisesTool, update_certificate::UpdateCertificateTool,
8 update_cheating_status::UpdateCheatingStatusTool,
9 },
10 client_tools::ask_multiple_choice_question::AskMultipleChoiceQuestionTool,
11 custom_tools::{
12 certificate_lookup::CertificateLookupTool,
13 course_configuration::CourseConfigurationTool, course_finder::CourseFinderTool,
14 course_material_search::CourseMaterialSearchTool, course_progress::CourseProgressTool,
15 course_structure::CourseStructureTool, document_lookup::DocumentLookupTool,
16 find_course::FindCourseTool, find_user::FindUserTool,
17 user_course_state::UserCourseStateTool, user_overview::UserOverviewTool,
18 },
19 output_limits::truncate_tool_output,
20 tool_authorization::{ToolRequirement, authorize_tool_call, requirements_are_satisfied},
21 },
22 prelude::*,
23 user_context::ChatbotTurnContext,
24};
25use headless_lms_models::chatbot_configurations::ToolCategory;
26use headless_lms_utils::json_schema_types::Schema;
27use indexmap::IndexMap;
28use serde::de::DeserializeOwned;
29use utoipa::ToSchema;
30
31pub mod action_tools;
32pub mod argument_parsing;
33pub mod client_tools;
34pub mod course_scope;
35pub mod custom_tools;
36pub mod output_limits;
37pub mod provider_tools;
38pub mod tool_authorization;
39pub mod tool_category;
40
41pub trait ChatbotToolDeclaration {
47 const NAME: &'static str;
50
51 fn offer_requirements(user_context: &ChatbotTurnContext) -> Vec<ToolRequirement>;
59
60 const CATEGORY: ToolCategory;
63
64 fn get_tool_definition() -> AzureLLMFunctionToolDefinition;
67}
68
69pub trait ChatbotTool: ChatbotToolDeclaration {
70 type Arguments: DeserializeOwned;
71
72 fn call_requirements(
79 arguments: &Self::Arguments,
80 user_context: &ChatbotTurnContext,
81 ) -> Vec<ToolRequirement>;
82
83 fn parse_arguments(args_string: String) -> ChatbotResult<Self::Arguments> {
90 serde_json::from_str(&args_string).map_err(|e| {
91 chatbot_err!(
92 InvalidToolArguments,
93 format!("Couldn't parse tool arguments. Arguments: {args_string}"),
94 e
95 )
96 })
97 }
98
99 fn from_db_and_arguments(
101 conn: &mut PgConnection,
102 app_config: &ApplicationConfiguration,
103 arguments: Self::Arguments,
104 user_context: &ChatbotTurnContext,
105 ) -> impl std::future::Future<Output = ChatbotResult<Self>> + Send
106 where
107 Self: Sized;
108
109 fn output(&self) -> String;
111
112 fn citations(&self) -> Vec<ToolCitation> {
115 Vec::new()
116 }
117
118 fn output_description_instructions(&self) -> Option<String>;
121
122 fn get_tool_output(&self) -> String {
124 delimited_tool_output(
125 &self.output(),
126 self.output_description_instructions().as_deref(),
127 )
128 }
129}
130
131#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
136#[serde(tag = "type", content = "data")]
137pub enum ClientToolAnswer {
138 Data {
140 #[schema(value_type = Object)]
144 result: serde_json::Value,
145 },
146}
147
148#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
156#[serde(rename_all = "snake_case")]
157pub enum ClientToolName {
158 AskMultipleChoiceQuestion,
159 GeneratePasswordResetLink,
160 ResetExercises,
161 UpdateCheatingStatus,
162 EditUserAccount,
163 UpdateCertificate,
164}
165
166impl ClientToolName {
167 pub const fn as_str(self) -> &'static str {
169 match self {
170 Self::AskMultipleChoiceQuestion => "ask_multiple_choice_question",
171 Self::GeneratePasswordResetLink => "generate_password_reset_link",
172 Self::ResetExercises => "reset_exercises",
173 Self::UpdateCheatingStatus => "update_cheating_status",
174 Self::EditUserAccount => "edit_user_account",
175 Self::UpdateCertificate => "update_certificate",
176 }
177 }
178}
179
180pub trait ClientChatbotTool: ChatbotToolDeclaration {
186 type Arguments;
188
189 type Response;
191
192 fn call_requirements(
195 arguments: &Self::Arguments,
196 user_context: &ChatbotTurnContext,
197 ) -> Vec<ToolRequirement>;
198
199 fn parse_arguments(arguments: &str) -> ChatbotResult<Self::Arguments>;
205
206 fn parse_response(
213 arguments: &Self::Arguments,
214 answer: &ClientToolAnswer,
215 ) -> ChatbotResult<Self::Response>;
216
217 fn output(arguments: &Self::Arguments, response: &Self::Response) -> String;
219
220 fn output_description_instructions() -> Option<String>;
222
223 fn get_tool_output(arguments: &Self::Arguments, response: &Self::Response) -> String {
226 delimited_tool_output(
227 &Self::output(arguments, response),
228 Self::output_description_instructions().as_deref(),
229 )
230 }
231}
232
233pub fn client_answer_data<T: DeserializeOwned>(answer: &ClientToolAnswer) -> ChatbotResult<T> {
235 let ClientToolAnswer::Data { result } = answer;
236 serde_json::from_value(result.clone()).map_err(|e| {
237 chatbot_err!(
238 InvalidToolAnswer,
239 "The answer is not in the shape this tool call expects.".to_string(),
240 e
241 )
242 })
243}
244
245fn delimited_tool_output(output: &str, instructions: Option<&str>) -> String {
253 let (output, truncation) = truncate_tool_output(output);
254 let mut formatted = format!("Result: [output]{output}[/output]");
255 let instructions = match (instructions, truncation) {
256 (Some(instructions), Some(truncation)) => Some(format!("{truncation} {instructions}")),
257 (Some(instructions), None) => Some(instructions.to_string()),
258 (None, Some(truncation)) => Some(truncation.to_string()),
259 (None, None) => None,
260 };
261 if let Some(instructions) = instructions {
262 formatted.push_str(&format!(
263 "\n\nInstructions for describing the output: [instructions]{instructions}[/instructions]"
264 ));
265 }
266 formatted
267}
268
269pub(crate) fn search_url(base_url: &str, path: &str, search: &str) -> String {
273 url::Url::parse(&format!("{base_url}{path}"))
274 .map(|mut url| {
275 url.query_pairs_mut().append_pair("search", search);
276 url.to_string()
277 })
278 .unwrap_or_else(|_| format!("{base_url}{path}"))
279}
280
281pub(crate) fn certificate_validation_url(base_url: &str, verification_id: &str) -> String {
285 format!("{base_url}/certificates/validate/{verification_id}")
286}
287
288pub fn no_parameters() -> Schema {
291 Schema::strict_object(IndexMap::new(), None)
292}
293
294#[cfg(test)]
297fn function_definitions(
298 definitions: Vec<AzureLLMToolDefinition>,
299) -> Vec<AzureLLMFunctionToolDefinition> {
300 definitions
301 .into_iter()
302 .filter_map(|definition| match definition {
303 AzureLLMToolDefinition::Function(function) => Some(function),
304 AzureLLMToolDefinition::Search(_) => None,
305 })
306 .collect()
307}
308
309pub struct ToolProperties<S> {
310 state: S,
311}
312
313pub struct ChatbotToolCallResult {
314 pub arguments: String,
316 pub output: String,
317 pub citations: Vec<ToolCitation>,
318}
319
320#[derive(Debug, PartialEq, Eq, Clone, Copy)]
323pub enum ClientToolCallRefusal {
324 CategoryDisabled,
326 NotAuthorized,
328}
329
330pub struct ActionToolOutcome {
332 pub output: String,
334 pub client_payload: Option<serde_json::Value>,
339}
340
341pub struct ToolCitation {
345 pub page_id: Uuid,
346 pub title: String,
347 pub snippet: String,
348 pub document_url: String,
349 pub citation_number: i32,
350}
351
352macro_rules! chatbot_tool_registry {
360 (
361 server_tools: [$($server_tool:ty),* $(,)?],
362 client_tools: [$($client_tool:ty),* $(,)?],
363 action_tools: [$($action_tool:ty),* $(,)?] $(,)?
364 ) => {
365 pub fn get_chatbot_tool_definitions() -> Vec<AzureLLMToolDefinition> {
370 vec![
371 $(AzureLLMToolDefinition::Function(<$server_tool as ChatbotToolDeclaration>::get_tool_definition()),)*
372 ]
373 }
374
375 pub async fn get_permitted_chatbot_tool_definitions(
381 conn: &mut PgConnection,
382 user_context: &ChatbotTurnContext,
383 ) -> ChatbotResult<Vec<AzureLLMToolDefinition>> {
384 let mut definitions = Vec::new();
385 $(
386 if user_context.enabled_tool_categories.contains(<$server_tool as ChatbotToolDeclaration>::CATEGORY)
387 && requirements_are_satisfied(
388 &mut *conn,
389 user_context,
390 &<$server_tool as ChatbotToolDeclaration>::offer_requirements(user_context),
391 )
392 .await?
393 {
394 definitions.push(AzureLLMToolDefinition::Function(
395 <$server_tool as ChatbotToolDeclaration>::get_tool_definition(),
396 ));
397 }
398 )*
399 Ok(definitions)
400 }
401
402 pub async fn call_chatbot_tool(
411 conn: &mut PgConnection,
412 app_config: &ApplicationConfiguration,
413 fn_name: &str,
414 fn_args: &str,
415 user_context: &ChatbotTurnContext,
416 ) -> ChatbotResult<ChatbotToolCallResult> {
417 $(
418 if fn_name == <$server_tool as ChatbotToolDeclaration>::NAME {
419 if !user_context.enabled_tool_categories.contains(<$server_tool as ChatbotToolDeclaration>::CATEGORY) {
420 return Err(chatbot_err!(
421 ToolUseError,
422 format!("This chatbot does not offer the tool {fn_name}")
423 ));
424 }
425 let arguments = <$server_tool as ChatbotTool>::parse_arguments(fn_args.to_owned())?;
426 if !requirements_are_satisfied(
427 &mut *conn,
428 user_context,
429 &<$server_tool as ChatbotTool>::call_requirements(&arguments, user_context),
430 )
431 .await?
432 {
433 return Err(chatbot_err!(
434 ToolUseError,
435 format!("The caller is not allowed to use the tool {fn_name}")
436 ));
437 }
438 let tool = <$server_tool as ChatbotTool>::from_db_and_arguments(&mut *conn, app_config, arguments, user_context).await?;
439 return Ok(ChatbotToolCallResult {
440 arguments: fn_args.to_owned(),
441 output: tool.get_tool_output(),
442 citations: tool.citations(),
443 });
444 }
445 )*
446 Err(chatbot_err!(
447 InvalidToolName,
448 format!("Incorrect or unknown function name: {fn_name}")
449 ))
450 }
451
452 pub async fn get_client_chatbot_tool_definitions(
458 conn: &mut PgConnection,
459 user_context: &ChatbotTurnContext,
460 ) -> ChatbotResult<Vec<AzureLLMToolDefinition>> {
461 let mut definitions = Vec::new();
462 $(
463 if user_context.enabled_tool_categories.contains(<$client_tool as ChatbotToolDeclaration>::CATEGORY)
464 && requirements_are_satisfied(
465 &mut *conn,
466 user_context,
467 &<$client_tool as ChatbotToolDeclaration>::offer_requirements(user_context),
468 )
469 .await?
470 {
471 definitions.push(AzureLLMToolDefinition::Function(
472 <$client_tool as ChatbotToolDeclaration>::get_tool_definition(),
473 ));
474 }
475 )*
476 $(
477 if user_context.enabled_tool_categories.contains(<$action_tool as ChatbotToolDeclaration>::CATEGORY)
478 && requirements_are_satisfied(
479 &mut *conn,
480 user_context,
481 &<$action_tool as ChatbotToolDeclaration>::offer_requirements(user_context),
482 )
483 .await?
484 {
485 definitions.push(AzureLLMToolDefinition::Function(
486 <$action_tool as ChatbotToolDeclaration>::get_tool_definition(),
487 ));
488 }
489 )*
490 Ok(definitions)
491 }
492
493 pub fn tool_is_answered_by_client(tool_name: &str) -> bool {
500 client_tool_category(tool_name).is_some()
501 }
502
503 pub async fn check_client_tool_call(
516 conn: &mut PgConnection,
517 user_context: &ChatbotTurnContext,
518 tool_name: &str,
519 arguments: &str,
520 ) -> ChatbotResult<Result<(), ClientToolCallRefusal>> {
521 $(
522 if tool_name == <$client_tool as ChatbotToolDeclaration>::NAME {
523 if !user_context.enabled_tool_categories.contains(<$client_tool as ChatbotToolDeclaration>::CATEGORY) {
524 return Ok(Err(ClientToolCallRefusal::CategoryDisabled));
525 }
526 let arguments = <$client_tool as ClientChatbotTool>::parse_arguments(arguments)?;
527 let authorized = requirements_are_satisfied(
528 conn,
529 user_context,
530 &<$client_tool as ClientChatbotTool>::call_requirements(&arguments, user_context),
531 )
532 .await?;
533 return Ok(if authorized { Ok(()) } else { Err(ClientToolCallRefusal::NotAuthorized) });
534 }
535 )*
536 $(
537 if tool_name == <$action_tool as ChatbotToolDeclaration>::NAME {
538 if !user_context.enabled_tool_categories.contains(<$action_tool as ChatbotToolDeclaration>::CATEGORY) {
539 return Ok(Err(ClientToolCallRefusal::CategoryDisabled));
540 }
541 let arguments = <$action_tool as ConfirmableActionTool>::parse_arguments(arguments)?;
542 let authorized = requirements_are_satisfied(
543 conn,
544 user_context,
545 &<$action_tool as ConfirmableActionTool>::call_requirements(&arguments, user_context),
546 )
547 .await?;
548 return Ok(if authorized { Ok(()) } else { Err(ClientToolCallRefusal::NotAuthorized) });
549 }
550 )*
551 Err(chatbot_err!(
552 InvalidToolName,
553 format!("No client tool is registered under the name {tool_name}")
554 ))
555 }
556
557 pub fn client_tool_category(tool_name: &str) -> Option<ToolCategory> {
560 $(
561 if tool_name == <$client_tool as ChatbotToolDeclaration>::NAME {
562 return Some(<$client_tool as ChatbotToolDeclaration>::CATEGORY);
563 }
564 )*
565 $(
566 if tool_name == <$action_tool as ChatbotToolDeclaration>::NAME {
567 return Some(<$action_tool as ChatbotToolDeclaration>::CATEGORY);
568 }
569 )*
570 None
571 }
572
573 pub fn tool_is_confirmable_action(tool_name: &str) -> bool {
577 $(
578 if tool_name == <$action_tool as ChatbotToolDeclaration>::NAME {
579 return true;
580 }
581 )*
582 false
583 }
584
585 pub async fn execute_action_tool(
598 conn: &mut PgConnection,
599 app_config: &ApplicationConfiguration,
600 tool_call: &headless_lms_models::chatbot_conversation_message_tool_calls::ChatbotConversationMessageToolCall,
601 answer: &ClientToolAnswer,
602 user_context: &ChatbotTurnContext,
603 ) -> ChatbotResult<ActionToolOutcome> {
604 let tool_name = tool_call.tool_name.as_str();
605 let arguments = &tool_call.arguments_json();
606 let tool_call_id = tool_call.id;
607 $(
608 if tool_name == <$action_tool as ChatbotToolDeclaration>::NAME {
609 if !user_context.enabled_tool_categories.contains(<$action_tool as ChatbotToolDeclaration>::CATEGORY) {
610 return Err(chatbot_err!(
611 ToolUseError,
612 format!("This chatbot does not offer the tool {tool_name}")
613 ));
614 }
615 let parsed_arguments =
616 <$action_tool as ConfirmableActionTool>::parse_arguments(arguments)?;
617 let confirm: ConfirmAnswer = client_answer_data(answer)?;
618
619 if !confirm.confirmed {
620 let instructions = <$action_tool as ConfirmableActionTool>::output_description_instructions(
621 &parsed_arguments,
622 None,
623 app_config,
624 );
625 return Ok(ActionToolOutcome {
626 output: delimited_tool_output(
627 &<$action_tool as ConfirmableActionTool>::declined_output(&parsed_arguments),
628 instructions.as_deref(),
629 ),
630 client_payload: None,
631 });
632 }
633
634 let Some(authorization) = authorize_tool_call::<$action_tool>(
635 &mut *conn,
636 user_context,
637 &<$action_tool as ConfirmableActionTool>::call_requirements(&parsed_arguments, user_context),
638 )
639 .await?
640 else {
641 return Err(chatbot_err!(
642 ToolUseError,
643 format!("The caller is not allowed to use the tool {tool_name}")
644 ));
645 };
646 let acting_user_id = authorization.acting_user_id();
647
648 let (executed, facts) = <$action_tool as ConfirmableActionTool>::execute(
649 &mut *conn,
650 app_config,
651 &parsed_arguments,
652 &authorization,
653 )
654 .await?;
655 let instructions = <$action_tool as ConfirmableActionTool>::output_description_instructions(
656 &parsed_arguments,
657 Some(&facts),
658 app_config,
659 );
660
661 headless_lms_models::chatbot_action_logs::insert(
662 &mut *conn,
663 headless_lms_models::chatbot_action_logs::NewChatbotActionLog {
664 acting_user_id,
665 tool_call_id,
666 tool_name: tool_name.to_string(),
667 arguments: serde_json::from_str(arguments)
668 .unwrap_or(serde_json::Value::String(arguments.to_string())),
669 target_user_id: executed.audit.target_user_id,
670 course_id: executed.audit.course_id,
671 summary: executed.audit.summary,
672 },
673 )
674 .await?;
675
676 return Ok(ActionToolOutcome {
677 output: delimited_tool_output(&executed.output, instructions.as_deref()),
678 client_payload: executed.client_payload,
679 });
680 }
681 )*
682 Err(chatbot_err!(
683 InvalidToolName,
684 format!("No action tool is registered under the name {tool_name}")
685 ))
686 }
687
688 pub fn client_tool_answer_output(
695 tool_name: &str,
696 arguments: &str,
697 answer: &ClientToolAnswer,
698 ) -> ChatbotResult<String> {
699 $(
700 if tool_name == <$client_tool as ChatbotToolDeclaration>::NAME {
701 let arguments = <$client_tool as ClientChatbotTool>::parse_arguments(arguments)?;
702 let response = <$client_tool as ClientChatbotTool>::parse_response(&arguments, answer)?;
703 return Ok(<$client_tool as ClientChatbotTool>::get_tool_output(&arguments, &response));
704 }
705 )*
706 Err(chatbot_err!(
707 InvalidToolName,
708 format!("No client tool is registered under the name {tool_name}")
709 ))
710 }
711 };
712}
713
714chatbot_tool_registry!(
715 server_tools: [
716 CourseProgressTool,
717 DocumentLookupTool,
718 CourseStructureTool,
719 CourseFinderTool,
720 FindUserTool,
721 FindCourseTool,
722 UserOverviewTool,
723 UserCourseStateTool,
724 CourseConfigurationTool,
725 CourseMaterialSearchTool,
726 CertificateLookupTool,
727 ],
728 client_tools: [AskMultipleChoiceQuestionTool],
729 action_tools: [
730 GeneratePasswordResetLinkTool,
731 ResetExercisesTool,
732 UpdateCheatingStatusTool,
733 EditUserAccountTool,
734 UpdateCertificateTool,
735 ],
736);
737
738#[cfg(test)]
743#[allow(dead_code, unused_variables, unused_mut)]
745mod generated_filter_tests {
746 use crate::azure_chatbot::azure::tools::LLMToolType;
747 use headless_lms_models::{
748 insert_data,
749 roles::UserRole,
750 test_helper::{Conn, init_app_conf},
751 };
752
753 use super::*;
754 use crate::chatbot_tools::tool_authorization::test_helpers::{
755 context, context_with_categories, course_role,
756 };
757
758 struct OpenTool;
759 struct TeacherTool;
760
761 fn definition(name: &str) -> AzureLLMFunctionToolDefinition {
762 AzureLLMFunctionToolDefinition {
763 tool_type: LLMToolType::Function,
764 name: name.to_string(),
765 description: "A tool that exists only in this test".to_string(),
766 parameters: no_parameters(),
767 strict: true,
768 }
769 }
770
771 impl ChatbotToolDeclaration for OpenTool {
772 const NAME: &'static str = "open_tool";
773 const CATEGORY: ToolCategory = ToolCategory::Interaction;
774
775 fn offer_requirements(_user_context: &ChatbotTurnContext) -> Vec<ToolRequirement> {
776 Vec::new()
777 }
778
779 fn get_tool_definition() -> AzureLLMFunctionToolDefinition {
780 definition(Self::NAME)
781 }
782 }
783
784 impl ClientChatbotTool for OpenTool {
785 type Arguments = ();
786 type Response = ();
787
788 fn call_requirements(
789 _arguments: &(),
790 _user_context: &ChatbotTurnContext,
791 ) -> Vec<ToolRequirement> {
792 Vec::new()
793 }
794
795 fn parse_arguments(_arguments: &str) -> ChatbotResult<()> {
796 Ok(())
797 }
798
799 fn parse_response(_arguments: &(), _answer: &ClientToolAnswer) -> ChatbotResult<()> {
800 Ok(())
801 }
802
803 fn output(_arguments: &(), _response: &()) -> String {
804 "answered".to_string()
805 }
806
807 fn output_description_instructions() -> Option<String> {
808 None
809 }
810 }
811
812 impl ChatbotToolDeclaration for TeacherTool {
813 const NAME: &'static str = "teacher_tool";
814 const CATEGORY: ToolCategory = ToolCategory::Interaction;
815
816 fn offer_requirements(user_context: &ChatbotTurnContext) -> Vec<ToolRequirement> {
817 vec![ToolRequirement::on_turn(
818 headless_lms_authorization::Action::Teach,
819 user_context,
820 )]
821 }
822
823 fn get_tool_definition() -> AzureLLMFunctionToolDefinition {
824 definition(Self::NAME)
825 }
826 }
827
828 impl ClientChatbotTool for TeacherTool {
829 type Arguments = ();
830 type Response = ();
831
832 fn call_requirements(
833 _arguments: &(),
834 _user_context: &ChatbotTurnContext,
835 ) -> Vec<ToolRequirement> {
836 Vec::new()
837 }
838
839 fn parse_arguments(_arguments: &str) -> ChatbotResult<()> {
840 Ok(())
841 }
842
843 fn parse_response(_arguments: &(), _answer: &ClientToolAnswer) -> ChatbotResult<()> {
844 Ok(())
845 }
846
847 fn output(_arguments: &(), _response: &()) -> String {
848 "answered".to_string()
849 }
850
851 fn output_description_instructions() -> Option<String> {
852 None
853 }
854 }
855
856 struct UncategorizedTool;
859
860 impl ChatbotToolDeclaration for UncategorizedTool {
861 const NAME: &'static str = "uncategorized_tool";
862 const CATEGORY: ToolCategory = ToolCategory::AdminSupportAccounts;
863
864 fn offer_requirements(_user_context: &ChatbotTurnContext) -> Vec<ToolRequirement> {
865 Vec::new()
866 }
867
868 fn get_tool_definition() -> AzureLLMFunctionToolDefinition {
869 definition(Self::NAME)
870 }
871 }
872
873 impl ClientChatbotTool for UncategorizedTool {
874 type Arguments = ();
875 type Response = ();
876
877 fn call_requirements(
878 _arguments: &(),
879 _user_context: &ChatbotTurnContext,
880 ) -> Vec<ToolRequirement> {
881 Vec::new()
882 }
883
884 fn parse_arguments(_arguments: &str) -> ChatbotResult<()> {
885 Ok(())
886 }
887
888 fn parse_response(_arguments: &(), _answer: &ClientToolAnswer) -> ChatbotResult<()> {
889 Ok(())
890 }
891
892 fn output(_arguments: &(), _response: &()) -> String {
893 "answered".to_string()
894 }
895
896 fn output_description_instructions() -> Option<String> {
897 None
898 }
899 }
900
901 chatbot_tool_registry!(
902 server_tools: [],
903 client_tools: [OpenTool, TeacherTool, UncategorizedTool],
904 action_tools: [],
905 );
906
907 async fn offered(conn: &mut PgConnection, user_context: &ChatbotTurnContext) -> Vec<String> {
908 function_definitions(
909 get_client_chatbot_tool_definitions(conn, user_context)
910 .await
911 .expect("the offered tools are decided"),
912 )
913 .into_iter()
914 .map(|definition| definition.name)
915 .collect()
916 }
917
918 #[tokio::test]
921 async fn every_mapping_covers_every_tool_in_the_list() {
922 insert_data!(:tx, :user, :org, :course);
923 let admin = context(
924 Some(user),
925 Some(course),
926 vec![
927 crate::chatbot_tools::tool_authorization::test_helpers::global_role(
928 user,
929 UserRole::Admin,
930 ),
931 ],
932 );
933
934 for name in [OpenTool::NAME, TeacherTool::NAME, UncategorizedTool::NAME] {
935 assert!(tool_is_answered_by_client(name), "{name}");
936 assert_eq!(
937 check_client_tool_call(tx.as_mut(), &admin, name, "{}")
938 .await
939 .unwrap_or_else(|e| panic!("{name}: {e:?}")),
940 Ok(()),
941 "{name}"
942 );
943 assert!(client_tool_category(name).is_some(), "{name}");
944 }
945 assert_eq!(
946 check_client_tool_call(tx.as_mut(), &admin, "open_tool_but_misspelled", "{}")
947 .await
948 .expect_err("no tool goes by that name")
949 .error_type(),
950 &ChatbotErrorType::InvalidToolName
951 );
952 assert_eq!(
953 client_tool_category(OpenTool::NAME),
954 Some(ToolCategory::Interaction)
955 );
956 assert_eq!(
957 client_tool_category(UncategorizedTool::NAME),
958 Some(ToolCategory::AdminSupportAccounts)
959 );
960 assert!(client_tool_category("open_tool_but_misspelled").is_none());
961
962 let rendered = client_tool_answer_output(
963 OpenTool::NAME,
964 "{}",
965 &ClientToolAnswer::Data {
966 result: serde_json::json!({}),
967 },
968 )
969 .expect("the answer renders");
970 assert!(rendered.contains("answered"), "{rendered}");
971 }
972
973 #[tokio::test]
974 async fn a_tool_is_kept_from_a_caller_who_is_not_authorized_for_it() {
975 insert_data!(:tx, :user, :org, :course);
976
977 let anonymous = context(None, Some(course), Vec::new());
978 assert_eq!(
979 offered(tx.as_mut(), &anonymous).await,
980 vec![
981 OpenTool::NAME.to_string(),
982 UncategorizedTool::NAME.to_string()
983 ],
984 "an anonymous caller is offered only what needs no privileges"
985 );
986
987 let learner = context(Some(user), Some(course), Vec::new());
988 assert_eq!(
989 offered(tx.as_mut(), &learner).await,
990 vec![
991 OpenTool::NAME.to_string(),
992 UncategorizedTool::NAME.to_string()
993 ]
994 );
995
996 let teacher = context(
997 Some(user),
998 Some(course),
999 vec![course_role(user, course, UserRole::Teacher)],
1000 );
1001 assert_eq!(
1002 offered(tx.as_mut(), &teacher).await,
1003 vec![
1004 OpenTool::NAME.to_string(),
1005 TeacherTool::NAME.to_string(),
1006 UncategorizedTool::NAME.to_string()
1007 ]
1008 );
1009 }
1010
1011 #[tokio::test]
1014 async fn a_tool_is_kept_from_a_configuration_that_does_not_enable_its_category() {
1015 insert_data!(:tx, :user, :org, :course);
1016
1017 let interaction_only = context_with_categories(
1018 Some(user),
1019 Some(course),
1020 Vec::new(),
1021 &[ToolCategory::Interaction],
1022 );
1023 assert_eq!(
1024 offered(tx.as_mut(), &interaction_only).await,
1025 vec![OpenTool::NAME.to_string()],
1026 "UncategorizedTool needs AdminSupportAccounts, which is not enabled"
1027 );
1028
1029 let admin_accounts_only = context_with_categories(
1030 Some(user),
1031 Some(course),
1032 Vec::new(),
1033 &[ToolCategory::AdminSupportAccounts],
1034 );
1035 assert_eq!(
1036 offered(tx.as_mut(), &admin_accounts_only).await,
1037 vec![UncategorizedTool::NAME.to_string()],
1038 "OpenTool needs Interaction, which is not enabled here"
1039 );
1040 }
1041}
1042
1043#[cfg(test)]
1044mod tests {
1045 use headless_lms_models::{
1046 insert_data,
1047 test_helper::{Conn, init_app_conf},
1048 };
1049
1050 use super::*;
1051 use crate::chatbot_tools::tool_authorization::test_helpers::context;
1052
1053 fn all_tool_definitions() -> Vec<AzureLLMFunctionToolDefinition> {
1056 let mut definitions = vec![
1057 <AskMultipleChoiceQuestionTool as ChatbotToolDeclaration>::get_tool_definition(),
1058 <GeneratePasswordResetLinkTool as ChatbotToolDeclaration>::get_tool_definition(),
1059 <ResetExercisesTool as ChatbotToolDeclaration>::get_tool_definition(),
1060 <UpdateCheatingStatusTool as ChatbotToolDeclaration>::get_tool_definition(),
1061 <EditUserAccountTool as ChatbotToolDeclaration>::get_tool_definition(),
1062 <UpdateCertificateTool as ChatbotToolDeclaration>::get_tool_definition(),
1063 ];
1064 definitions.extend(function_definitions(get_chatbot_tool_definitions()));
1065 definitions
1066 }
1067
1068 #[test]
1071 fn tool_definitions_are_strict_and_uniquely_named() {
1072 let mut names = std::collections::HashSet::new();
1073 for definition in all_tool_definitions() {
1074 let json =
1075 serde_json::to_value(&definition).expect("The tool definition serializes to JSON");
1076 assert_eq!(json["strict"], true, "{json}");
1077 assert_eq!(json["parameters"]["additionalProperties"], false, "{json}");
1078 assert!(
1079 names.insert(json["name"].to_string()),
1080 "Two tools are registered under the name {}",
1081 json["name"]
1082 );
1083 }
1084 assert!(!names.is_empty());
1085 }
1086
1087 #[test]
1093 fn tool_definitions_serialize_byte_identically_across_requests() {
1094 let serialize = || {
1095 serde_json::to_string(&all_tool_definitions())
1096 .expect("The tool definitions serialize to JSON")
1097 };
1098 let first = serialize();
1099 for _ in 0..50 {
1100 assert_eq!(
1101 serialize(),
1102 first,
1103 "Tool definitions serialize differently between two requests, which misses the prompt cache"
1104 );
1105 }
1106 }
1107
1108 #[test]
1111 fn no_tool_is_both_run_by_the_server_and_answered_by_the_client() {
1112 for definition in get_chatbot_tool_definitions() {
1113 let AzureLLMToolDefinition::Function(function) = definition else {
1114 continue;
1115 };
1116 assert!(
1117 !tool_is_answered_by_client(&function.name),
1118 "{} is registered in both tool registries",
1119 function.name
1120 );
1121 }
1122 assert!(tool_is_answered_by_client(
1123 <AskMultipleChoiceQuestionTool as ChatbotToolDeclaration>::NAME
1124 ));
1125 assert!(!tool_is_answered_by_client("a_tool_the_llm_made_up"));
1126 }
1127
1128 #[tokio::test]
1131 async fn the_multiple_choice_question_is_offered_anonymously() {
1132 insert_data!(:tx, :user, :org, :course);
1133 let name = <AskMultipleChoiceQuestionTool as ChatbotToolDeclaration>::NAME.to_string();
1134 let anonymous = context(None, Some(course), Vec::new());
1135
1136 let offered: Vec<String> = function_definitions(
1137 get_client_chatbot_tool_definitions(tx.as_mut(), &anonymous)
1138 .await
1139 .expect("the offered tools are decided"),
1140 )
1141 .into_iter()
1142 .map(|definition| definition.name)
1143 .collect();
1144 assert!(offered.contains(&name), "{offered:?}");
1145 }
1146}