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