Skip to main content

headless_lms_chatbot/chatbot_tools/
mod.rs

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
42/// What a tool is called and how it is declared to the LLM.
43///
44/// Shared by the tools the server runs ([ChatbotTool]) and the tools the client answers
45/// ([ClientChatbotTool]): the two differ in who produces the output, not in how the tool is
46/// advertised.
47pub trait ChatbotToolDeclaration {
48    /// The name the LLM calls this tool by. The registries dispatch on it and
49    /// [Self::get_tool_definition] must advertise it, so the two cannot drift apart.
50    const NAME: &'static str;
51
52    /// What the caller must be allowed to do against the turn itself for this tool to be offered
53    /// to the LLM at all.
54    ///
55    /// Coarse on purpose: no call has named a target yet, so this asks about the chatbot's own
56    /// course (or global permissions for a chatbot with none). The binding check is the tool's
57    /// `call_requirements`, against what the call actually targets. Empty for a tool the
58    /// chatbot's own access check already covers.
59    fn offer_requirements(user_context: &ChatbotTurnContext) -> Vec<ToolRequirement>;
60
61    /// Which configured category of tools this belongs to. A chatbot offers it only if its
62    /// configuration lists this category; the caller must still be authorized for it.
63    const CATEGORY: ToolCategory;
64
65    /// The definition sent to the LLM as part of a chat request. Azure rejects it unless `strict`
66    /// is true and the parameter schema forbids additional properties.
67    fn get_tool_definition() -> AzureLLMFunctionToolDefinition;
68}
69
70pub trait ChatbotTool: ChatbotToolDeclaration {
71    type Arguments: DeserializeOwned;
72
73    /// What the caller must be allowed to do against what this call actually targets.
74    ///
75    /// The model picks the target, so this is the check that binds; the tool must name every
76    /// resource the call touches, since all of them are required. `user_context` is here for the
77    /// tools whose arguments name a target only by omission, leaving it to default to the
78    /// chatbot's own course.
79    fn call_requirements(
80        arguments: &Self::Arguments,
81        user_context: &ChatbotTurnContext,
82    ) -> Vec<ToolRequirement>;
83
84    /// Parses and validates the arguments the LLM called the tool with.
85    ///
86    /// The LLM is free to emit values the schema forbids, so every constraint the tool body
87    /// relies on has to be rejected here rather than assumed; the derived deserialization the
88    /// default body does is only as strict as the argument type. Fails with
89    /// [ChatbotErrorType::InvalidToolArguments], which is reported to the LLM.
90    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    /// Create a new instance after parsing arguments
101    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    /// Output the result of the tool call in LLM-readable form
112    fn output(&self) -> String;
113
114    /// Page references this call's output cites, numbered as the tool told the model to cite
115    /// them. Empty for a tool whose output is not quotable material.
116    fn citations(&self) -> Vec<ToolCitation> {
117        Vec::new()
118    }
119
120    /// Additional instructions for the LLM on how to describe and
121    /// communicate the tool output. Just-in-time prompt.
122    fn output_description_instructions(&self) -> Option<String>;
123
124    /// Get and format tool output and instructions for LLM
125    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/// What a client answered a tool call with.
134///
135/// The tool the call belongs to decides what shape the answer has to be in and what the model is
136/// told it means.
137#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
138#[serde(tag = "type", content = "data")]
139pub enum ClientToolAnswer {
140    /// The tool ran on the client. `result` is JSON of whatever shape the tool defines.
141    Data {
142        /// An untyped object in the OpenApi schema: the shape belongs to the tool, so it is not
143        /// known here. Unlike the tool call arguments we hand back to clients, this one is built
144        /// by the client, so declaring it a string would make the generated binding unusable.
145        #[schema(value_type = Object)]
146        result: serde_json::Value,
147    },
148}
149
150/// The name of a client tool, generated into the frontend as a string union so it names one of
151/// [ClientChatbotTool::NAME] by construction instead of by a hand-copied literal.
152///
153/// The bounds a tool enforces on its arguments and the shape of its answer stay hand-written on
154/// the frontend: routing those through the OpenAPI schema would need either a schema per tool or
155/// widening the argument and answer types this crate uses to serialize them, for a part of the
156/// contract that only fails loudly, unlike the name.
157#[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    /// The wire name [ChatbotToolDeclaration::NAME] must equal for the tool this variant names.
170    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
182/// A tool whose output the client produces instead of server code.
183///
184/// The LLM calls it like any other tool, but the turn suspends: the call is recorded without an
185/// output, the client answers it through the tool-response endpoint, and that answer becomes the
186/// output the resumed turn reads.
187pub trait ClientChatbotTool: ChatbotToolDeclaration {
188    /// The arguments of a call, as [Self::parse_arguments] has validated them.
189    type Arguments;
190
191    /// The client's answer, as [Self::parse_response] has checked it against the call.
192    type Response;
193
194    /// What the caller must be allowed to do against what this call actually targets. Checked
195    /// before the turn suspends on the call and again when its answer arrives.
196    fn call_requirements(
197        arguments: &Self::Arguments,
198        user_context: &ChatbotTurnContext,
199    ) -> Vec<ToolRequirement>;
200
201    /// Parses and validates the arguments the LLM called the tool with.
202    ///
203    /// The LLM is free to emit values the schema forbids, so every constraint the client and the
204    /// rendering rely on has to be rejected here rather than assumed. Fails with
205    /// [ChatbotErrorType::InvalidToolArguments], which is reported to the LLM.
206    fn parse_arguments(arguments: &str) -> ChatbotResult<Self::Arguments>;
207
208    /// Parses the client's answer to a call made with `arguments`.
209    ///
210    /// The answer decides what the model is told the user said, so an implementor must check it
211    /// against what `arguments` actually offered instead of trusting the client to keep to it.
212    /// Fails with [ChatbotErrorType::InvalidToolAnswer], the one chatbot error the client is
213    /// told about.
214    fn parse_response(
215        arguments: &Self::Arguments,
216        answer: &ClientToolAnswer,
217    ) -> ChatbotResult<Self::Response>;
218
219    /// The answer in LLM-readable form.
220    fn output(arguments: &Self::Arguments, response: &Self::Response) -> String;
221
222    /// Just-in-time instructions for the LLM on what to do with the answer.
223    fn output_description_instructions() -> Option<String>;
224
225    /// The tool output the resumed turn reads, with the answer delimited from the instructions
226    /// about it.
227    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
235/// The data a client answered with, as the tool's own response shape.
236pub 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
247/// Wraps tool output for the LLM so that data from outside the conversation cannot be read as
248/// instructions about it, and enforces the one size limit every tool output has to respect.
249///
250/// The limit lives here rather than in each tool because an output the conversation cannot store
251/// ends the whole turn, so no tool may be trusted to opt in. A tool that would rather shape its
252/// own result than be cut off should bound its lists with
253/// [CappedList](output_limits::CappedList) instead of relying on this.
254fn 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
271/// An absolute `{base_url}{path}` URL with a single percent-encoded `search` query parameter.
272/// `search` can contain characters (e.g. a `+` in an email's local part) that are not safe to
273/// interpolate into a query string directly.
274pub(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
283/// The public page a certificate's verification id addresses, which is also where its image is
284/// viewed. Kept in sync by hand with `certificateValidateRoute` in
285/// `shared-module/packages/common/src/utils/routes.ts`.
286pub(crate) fn certificate_validation_url(base_url: &str, verification_id: &str) -> String {
287    format!("{base_url}/certificates/validate/{verification_id}")
288}
289
290/// The parameter schema of a tool the LLM calls without arguments. Azure still requires a strict
291/// object schema that forbids additional properties.
292pub fn no_parameters() -> Schema {
293    Schema::strict_object(IndexMap::new(), None)
294}
295
296/// The function definitions of a tool list, dropping the provider's own tools, which have no name
297/// of their own to dispatch on.
298#[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    /// The arguments the tool was called with, as JSON, persisted with the function call message.
317    pub arguments: String,
318    pub output: String,
319    pub citations: Vec<ToolCitation>,
320}
321
322/// Why a client tool call cannot go ahead, when the client's answer to it (or the plan to suspend
323/// on it) has to become an explanation for the model rather than an error.
324#[derive(Debug, PartialEq, Eq, Clone, Copy)]
325pub enum ClientToolCallRefusal {
326    /// The chatbot's configuration no longer offers this kind of tool.
327    CategoryDisabled,
328    /// The caller may not make this call against what it targets.
329    NotAuthorized,
330}
331
332/// What executing a confirmed (or declining an unconfirmed) action tool call produced.
333pub struct ActionToolOutcome {
334    /// The tool output the resumed turn reads.
335    pub output: String,
336    /// The data the confirming admin's browser gets as an [ActionExecuted] stream event, never
337    /// persisted and never shown to the model. `None` for a decline.
338    ///
339    /// [ActionExecuted]: crate::azure_chatbot::events::ChatbotChatStreamEvent::ActionExecuted
340    pub client_payload: Option<serde_json::Value>,
341}
342
343/// One page reference a tool call's output cites, ready to become a
344/// [headless_lms_models::chatbot_conversation_messages_citations::ChatbotConversationMessageCitation]
345/// row once the message it was attached beside is stored.
346pub 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
354/// Defines the chatbot tools the LLM can call, split by who produces the output of a call.
355///
356/// Both registries are generated from this one list: the definitions offered to the LLM, the
357/// dispatcher that runs a server tool, the check that decides a call suspends the turn instead,
358/// what a tool requires of its caller and the rendering of a client's answer. A tool therefore
359/// cannot be advertised without being callable, and a tool's kind is stated in one place rather
360/// than implied by which list it was pasted into.
361macro_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        /// Every tool the server runs, whoever is allowed to use it.
368        ///
369        /// For callers that only need the listing. Use [get_permitted_chatbot_tool_definitions]
370        /// to decide what a request may offer the LLM.
371        pub fn get_chatbot_tool_definitions() -> Vec<AzureLLMToolDefinition> {
372            vec![
373                $(AzureLLMToolDefinition::Function(<$server_tool as ChatbotToolDeclaration>::get_tool_definition()),)*
374            ]
375        }
376
377        /// The server tool definitions this request may offer the LLM.
378        ///
379        /// A tool is offered only to a caller who passes its offer requirements, and the roles
380        /// that decides are fetched at most once for the whole request. Offering is not a
381        /// promise: what a call may target is decided again when it is made.
382        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        /// Run the chatbot tool the LLM asked for and return its arguments and its
405        /// LLM-readable output.
406        ///
407        /// `fn_args` is the raw argument JSON from the LLM; each tool parses it itself and
408        /// tools that take no arguments ignore it. Arguments are parsed before the caller is
409        /// authorized, because what the call targets is what decides the answer. Fails with
410        /// `InvalidToolName` when no tool claims `fn_name`, which happens when the LLM
411        /// hallucinates a tool.
412        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        /// The client tool definitions this request may offer the LLM.
456        ///
457        /// A tool is offered only to a caller who passes its offer requirements, and the roles
458        /// that decides are fetched at most once for the whole request. Offering is not a
459        /// promise: what a call may target is decided again when it is made.
460        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        /// Whether the client answers this tool call instead of server code, which is what decides
497        /// that the turn suspends rather than answering the call itself.
498        ///
499        /// The one place that knowledge lives, so the stored `tool_kind` and the engine cannot
500        /// disagree. A name no client tool claims is left to the server dispatcher, which reports
501        /// a hallucinated name to the LLM instead of suspending on it.
502        pub fn tool_is_answered_by_client(tool_name: &str) -> bool {
503            client_tool_category(tool_name).is_some()
504        }
505
506        /// Checks that a client tool call can go ahead: its arguments parse, and its caller may
507        /// make it against what those arguments target.
508        ///
509        /// Called both before the turn suspends on the call and when its answer arrives, since
510        /// nothing bounds how long a call waits and a role can be revoked while it does. A
511        /// [ClientToolCallRefusal] is not a failure: the caller turns it into an explanation the
512        /// model reads, because the turn stays stuck until its call has some output.
513        ///
514        /// Fails with [ChatbotErrorType::InvalidToolArguments] for a call the tool would reject,
515        /// which can never be answered and so has to fail while the turn can still report it to
516        /// the LLM, and with [ChatbotErrorType::InvalidToolName] when no client tool goes by
517        /// `tool_name`.
518        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        /// The category a client tool (including an action tool) belongs to, or `None` when no
561        /// client-answered tool goes by that name.
562        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        /// Whether `tool_name` is a [ConfirmableActionTool] rather than a pure
577        /// [ClientChatbotTool]: its answer runs a mutation through [execute_action_tool] instead
578        /// of being rendered directly by [client_tool_answer_output].
579        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        /// Runs the confirmed (or records the declined) action tool call the LLM asked for.
589        ///
590        /// `tool_call`'s row id is stored on the audit row so it traces back to the conversation.
591        /// The tool's category and its call requirements are both re-checked here, immediately
592        /// before the mutation, rather than trusted from whatever planned the call: the proof
593        /// [ConfirmableActionTool::execute] requires can only be minted by that check.
594        ///
595        /// Fails with [ChatbotErrorType::InvalidToolAnswer] when `answer` is not a
596        /// [ConfirmAnswer], with [ChatbotErrorType::ToolUseError] when the caller may no longer
597        /// use the tool, and with [ChatbotErrorType::InvalidToolName] when no action tool goes by
598        /// `tool_call`'s name. A declined answer never touches the database beyond what the
599        /// caller writes for the closed call itself, and needs no authorization of its own.
600        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        /// Turns a client's answer into the tool output the resumed turn reads.
692        ///
693        /// `arguments` is the argument JSON the suspended call was recorded with, re-validated
694        /// here because the answer is only meaningful against what was actually offered. Fails
695        /// with [ChatbotErrorType::InvalidToolAnswer] when the answer does not fit the call, and
696        /// with [ChatbotErrorType::InvalidToolName] when no client tool goes by `tool_name`.
697        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/// A second registry, generated from tools that exist only here.
742///
743/// The one real client tool is offered to everyone, so the generated authorization filter can
744/// only be seen letting a tool through. This registry has a tool it keeps out.
745#[cfg(test)]
746// The empty server list generates a server half that nothing here calls.
747#[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    /// Requires nothing, but sits in a category distinct from [OpenTool]/[TeacherTool] — proves
860    /// the category filter keeps an authorized tool out on its own.
861    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    /// The registry's mappings all come from its one list, so a tool that is in the list is in
922    /// every one of them.
923    #[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    /// The category filter keeps an authorized tool out on its own: [UncategorizedTool] requires
1015    /// nothing of its caller, but its category is not in the enabled set.
1016    #[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    /// Every definition either registry can put in a request, whether the server or the client
1057    /// answers the call.
1058    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    /// Azure rejects tool definitions that are not strict or that allow additional
1072    /// properties, and two tools sharing a name would make one of them unreachable.
1073    #[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    /// Tool definitions sit at the front of every prompt and Azure's prompt cache matches an exact
1091    /// prefix, so a definition that serializes differently between two requests misses the cache
1092    /// for the whole prompt. `RandomState` reseeds per map instance, which is why the parameter
1093    /// schemas must not be built from a `HashMap`. Repeated because one comparison can match by
1094    /// chance even when the ordering is random.
1095    #[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    /// The two registries dispatch on the same names, and a name in both would either be run by
1112    /// the server or suspend the turn depending on which check ran first.
1113    #[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    /// Asking the learner to pick an answer needs no privileges, so even an anonymous visitor of
1132    /// a public chatbot is offered it.
1133    #[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}