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