headless_lms_chatbot/chatbot_tools/action_tools/mod.rs
1//! The confirm-and-execute primitive: a client tool whose confirmed answer runs a privileged
2//! mutation on the server instead of only rendering data the client already had.
3//!
4//! [ClientChatbotTool](crate::chatbot_tools::ClientChatbotTool) cannot carry this: its
5//! `parse_response`/`output` are synchronous and get no [PgConnection], no
6//! [ApplicationConfiguration] and no proof of who is acting. [ConfirmableActionTool] is the one place that
7//! threads those in, so every action tool gets the same confirm-parsing, exactly-once guard,
8//! transaction handling and audit write instead of a hand-rolled copy each.
9
10use serde::Deserialize;
11use sqlx::PgConnection;
12use uuid::Uuid;
13
14pub mod reset_exercises;
15
16use headless_lms_base::config::ApplicationConfiguration;
17
18use crate::chatbot_tools::ChatbotToolDeclaration;
19use crate::chatbot_tools::tool_authorization::{ToolAuthorization, ToolRequirement};
20use crate::prelude::{BackendError, ChatbotError, ChatbotErrorType, ChatbotResult, chatbot_err};
21use crate::user_context::ChatbotTurnContext;
22
23pub mod edit_user_account;
24pub mod generate_password_reset_link;
25pub mod update_certificate;
26pub mod update_cheating_status;
27
28/// The one answer shape every action tool accepts.
29#[derive(Deserialize)]
30pub struct ConfirmAnswer {
31 pub confirmed: bool,
32}
33
34/// What executing a confirmed action produced.
35pub struct ExecutedAction {
36 /// Model-facing description of what happened. Never contains secrets.
37 pub output: String,
38 /// Data for the confirming admin's browser only (e.g. the reset link). Sent as a stream
39 /// event, never persisted, never shown to the model.
40 pub client_payload: Option<serde_json::Value>,
41 /// What the audit row records about the action's target and effect.
42 pub audit: ActionAuditFields,
43}
44
45/// What the audit row records about a confirmed action, beyond who ran it and what tool it was.
46pub struct ActionAuditFields {
47 pub target_user_id: Option<Uuid>,
48 pub course_id: Option<Uuid>,
49 /// One human-readable sentence, e.g. "Reset 3 exercises for jane@example.com in Course X".
50 /// Must never contain secrets: this is stored in `chatbot_action_logs`.
51 pub summary: String,
52}
53
54/// A client tool whose confirmed answer performs a privileged mutation on the server.
55///
56/// Suspends the turn like [ClientChatbotTool](crate::chatbot_tools::ClientChatbotTool); the
57/// client answers with [ConfirmAnswer]. A declined answer produces [Self::declined_output]
58/// without touching the database. A confirmed answer runs [Self::execute] inside the same
59/// transaction as the audit insert and the recorded tool output, guarded by
60/// `lock_unanswered_for_execution` so it can run at most once.
61pub trait ConfirmableActionTool: ChatbotToolDeclaration {
62 type Arguments;
63
64 /// Extra facts [Self::execute] gathers while performing the mutation, used only to decide
65 /// what [Self::output_description_instructions] should mention for this particular call. `()`
66 /// for a tool with nothing worth gating on.
67 type Facts;
68
69 fn parse_arguments(arguments: &str) -> ChatbotResult<Self::Arguments>;
70
71 /// What the caller must be allowed to do against what this call actually targets. Checked
72 /// before the turn suspends on the call and again immediately before the mutation runs.
73 fn call_requirements(
74 arguments: &Self::Arguments,
75 user_context: &ChatbotTurnContext,
76 ) -> Vec<ToolRequirement>;
77
78 /// Performs the mutation. Must re-verify every model-supplied display field against the
79 /// database (case-insensitively for emails) and refuse with a descriptive error rather than
80 /// mutate on a mismatch, so a wrong or stale display can never mutate a row it doesn't
81 /// describe. `authorization` both proves the confirming admin was checked against
82 /// this call's own requirements and names them, so the mutation cannot run ahead of the
83 /// check and the audit row cannot credit anyone else.
84 fn execute(
85 conn: &mut PgConnection,
86 app_config: &ApplicationConfiguration,
87 arguments: &Self::Arguments,
88 authorization: &ToolAuthorization<Self>,
89 ) -> impl std::future::Future<Output = ChatbotResult<(ExecutedAction, Self::Facts)>> + Send
90 where
91 Self: Sized;
92
93 fn declined_output(_arguments: &Self::Arguments) -> String {
94 "The admin declined the action. Nothing was changed. Do not retry unless asked to."
95 .to_string()
96 }
97
98 /// Just-in-time instructions for the LLM on how to describe the outcome. `facts` is `None` on
99 /// a declined answer (nothing executed) and `Some` after a successful [Self::execute], so
100 /// instructions can be conditioned on what this call actually did instead of always
101 /// including everything that could ever be true. `app_config` is available on both branches
102 /// (unlike `facts`) so a verification link can be offered even on a decline.
103 fn output_description_instructions(
104 arguments: &Self::Arguments,
105 facts: Option<&Self::Facts>,
106 app_config: &ApplicationConfiguration,
107 ) -> Option<String>;
108}
109
110/// Unicode-aware case-insensitive equality for a model-supplied display field against its
111/// current database value. `eq_ignore_ascii_case` would reject a match for cased letters outside
112/// ASCII (e.g. Finnish å/Ö) that differ only in case, spuriously refusing a genuine match.
113pub fn display_field_matches(actual: &str, supplied: &str) -> bool {
114 actual.to_lowercase() == supplied.to_lowercase()
115}
116
117/// Re-verifies a model-supplied display field against `actual`, per [ConfirmableActionTool::execute]'s
118/// contract: refuses with a descriptive error naming `field_name` and `rerun_tool` instead of
119/// mutating on a mismatch.
120pub fn verify_display_field(
121 field_name: &str,
122 actual: &str,
123 supplied: &str,
124 rerun_tool: &str,
125) -> ChatbotResult<()> {
126 if display_field_matches(actual, supplied) {
127 Ok(())
128 } else {
129 Err(chatbot_err!(
130 ToolUseError,
131 format!(
132 "The {field_name} does not match the current record. Re-run {rerun_tool} and try again."
133 )
134 ))
135 }
136}