Skip to main content

headless_lms_models/
chatbot_conversation_message_tool_calls.rs

1use serde_json::Value;
2use utoipa::ToSchema;
3
4use crate::prelude::*;
5
6/// Who answers a tool call, which decides what happens to a call that has no output yet.
7#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Type, ToSchema)]
8#[sqlx(type_name = "tool_kind", rename_all = "kebab-case")]
9#[serde(rename_all = "snake_case")]
10pub enum ToolKind {
11    /// Server code, in the same turn that made the call.
12    Function,
13    /// The provider, before we ever see the call.
14    AzureAiSearch,
15    /// The client, in a later request. Until then the turn is suspended and the call is
16    /// legitimately unanswered, which is why the unanswered-call sweep has to leave it alone.
17    ClientTool,
18}
19
20impl ToolKind {
21    /// Whether a call of this kind without an output is a turn waiting for the client rather than
22    /// one that died.
23    pub fn is_answered_by_client(self) -> bool {
24        matches!(self, Self::ClientTool)
25    }
26
27    /// Whether the provider answers the call itself. That is the only distinction the wire shape of
28    /// a call depends on: every other kind is an ordinary function call to the provider, whoever
29    /// produces its output on this end.
30    pub fn is_provider_tool(self) -> bool {
31        match self {
32            ToolKind::AzureAiSearch => true,
33            ToolKind::Function | ToolKind::ClientTool => false,
34        }
35    }
36}
37
38#[derive(Clone, PartialEq, Deserialize, Serialize, Debug, ToSchema)]
39pub struct ChatbotConversationMessageToolCall {
40    pub id: Uuid,
41    pub created_at: DateTime<Utc>,
42    pub updated_at: DateTime<Utc>,
43    pub deleted_at: Option<DateTime<Utc>>,
44    pub chatbot_conversation_message_id: Uuid,
45    pub tool_name: String,
46    /// Is type = string in the OpenApi schema, because there is no known shape
47    /// for this JSON value/object so we treat it as 'string' instead of
48    /// 'unknown' in the API autogenerated types. Since it's valid JSON, we save
49    /// it as JSON in our DB.
50    #[schema(value_type = String)]
51    pub tool_arguments: Value,
52    pub tool_call_id: String,
53    pub tool_kind: ToolKind,
54    pub response_id: String,
55}
56
57impl ChatbotConversationMessageToolCall {
58    /// A call ready to be inserted, recording `arguments` as the JSON text the model wrote for it.
59    ///
60    /// Storing the text rather than the object it parses into is what lets [`Self::arguments_json`]
61    /// hand it back to the model unchanged; go through here rather than filling
62    /// [`Self::tool_arguments`] yourself.
63    pub fn new(
64        tool_call_id: String,
65        tool_name: String,
66        arguments: String,
67        tool_kind: ToolKind,
68        response_id: String,
69    ) -> Self {
70        Self {
71            tool_call_id,
72            tool_name,
73            tool_arguments: Value::String(arguments),
74            tool_kind,
75            response_id,
76            ..Default::default()
77        }
78    }
79
80    /// The argument JSON of the call. A call is recorded with the JSON text the model produced
81    /// rather than with its parsed shape, so [`Self::tool_arguments`] is normally a JSON string.
82    ///
83    /// Use this rather than serializing the stored value, which would escape that text a second
84    /// time and both feed the model garbage and break the prefix its prompt cache matches on.
85    pub fn arguments_json(&self) -> String {
86        match &self.tool_arguments {
87            Value::String(json) => json.clone(),
88            parsed => parsed.to_string(),
89        }
90    }
91}
92
93impl Default for ChatbotConversationMessageToolCall {
94    fn default() -> Self {
95        Self {
96            id: Uuid::nil(),
97            created_at: Default::default(),
98            updated_at: Default::default(),
99            deleted_at: None,
100            chatbot_conversation_message_id: Uuid::nil(),
101            tool_name: Default::default(),
102            tool_arguments: Default::default(),
103            tool_call_id: Default::default(),
104            tool_kind: ToolKind::Function,
105            response_id: Default::default(),
106        }
107    }
108}
109
110pub async fn insert(
111    conn: &mut PgConnection,
112    input: ChatbotConversationMessageToolCall,
113    msg_id: Uuid,
114) -> ModelResult<ChatbotConversationMessageToolCall> {
115    let res = sqlx::query_as!(
116        ChatbotConversationMessageToolCall,
117        r#"
118INSERT INTO chatbot_conversation_message_tool_calls (
119    chatbot_conversation_message_id,
120    tool_name,
121    tool_arguments,
122    tool_call_id,
123    tool_kind,
124    response_id
125  )
126VALUES ($1, $2, $3, $4, $5, $6)
127RETURNING *
128        "#,
129        msg_id,
130        input.tool_name,
131        input.tool_arguments,
132        input.tool_call_id,
133        input.tool_kind as ToolKind,
134        input.response_id
135    )
136    .fetch_one(conn)
137    .await?;
138    Ok(res)
139}
140
141pub async fn get_by_id(
142    conn: &mut PgConnection,
143    id: Uuid,
144) -> ModelResult<ChatbotConversationMessageToolCall> {
145    let res = sqlx::query_as!(
146        ChatbotConversationMessageToolCall,
147        r#"
148SELECT *
149FROM chatbot_conversation_message_tool_calls
150WHERE id = $1
151  AND deleted_at IS NULL
152        "#,
153        id
154    )
155    .fetch_one(conn)
156    .await?;
157    Ok(res)
158}
159
160pub async fn get_by_message_id(
161    conn: &mut PgConnection,
162    msg_id: Uuid,
163) -> ModelResult<Option<ChatbotConversationMessageToolCall>> {
164    let res = sqlx::query_as!(
165        ChatbotConversationMessageToolCall,
166        r#"
167SELECT *
168FROM chatbot_conversation_message_tool_calls
169WHERE chatbot_conversation_message_id = $1
170  AND deleted_at IS NULL
171        "#,
172        msg_id
173    )
174    .fetch_optional(conn)
175    .await?;
176    Ok(res)
177}
178
179/// The call of this conversation that the provider gave `tool_call_id`, answered or not.
180///
181/// Scoped to the conversation because `tool_call_id` is the provider's string and can repeat in
182/// another conversation, which would otherwise let a caller reach a call that is not theirs.
183pub async fn get_by_conversation_and_tool_call_id(
184    conn: &mut PgConnection,
185    conversation_id: Uuid,
186    tool_call_id: &str,
187) -> ModelResult<Option<ChatbotConversationMessageToolCall>> {
188    let res = sqlx::query_as!(
189        ChatbotConversationMessageToolCall,
190        r#"
191SELECT ccmtc.*
192FROM chatbot_conversation_message_tool_calls AS ccmtc
193  JOIN chatbot_conversation_messages AS ccm ON ccm.id = ccmtc.chatbot_conversation_message_id
194WHERE ccm.conversation_id = $1
195  AND ccmtc.tool_call_id = $2
196  AND ccmtc.deleted_at IS NULL
197  AND ccm.deleted_at IS NULL
198        "#,
199        conversation_id,
200        tool_call_id
201    )
202    .fetch_optional(conn)
203    .await?;
204    Ok(res)
205}
206
207pub async fn delete(
208    conn: &mut PgConnection,
209    id: Uuid,
210) -> ModelResult<ChatbotConversationMessageToolCall> {
211    let res = sqlx::query_as!(
212        ChatbotConversationMessageToolCall,
213        r#"
214UPDATE chatbot_conversation_message_tool_calls
215SET deleted_at = NOW()
216WHERE id = $1
217  AND deleted_at IS NULL
218RETURNING *
219        "#,
220        id
221    )
222    .fetch_one(conn)
223    .await?;
224    Ok(res)
225}
226
227/// Every tool call of the conversation that has no output yet, whatever its kind.
228///
229/// A call is unanswered either because the turn that made it died, which leaves the conversation
230/// in a shape the LLM rejects until the call is answered, or because it is a [ToolKind::ClientTool]
231/// call whose turn is suspended waiting for the client. Callers decide which of the two they are
232/// looking at from `tool_kind`; the query does not.
233///
234/// `tool_call_id` is a provider-supplied string rather than a foreign key and is not unique
235/// across conversations, so outputs only count as answers within the same conversation.
236pub async fn get_unanswered_tool_calls_for_conversation(
237    conn: &mut PgConnection,
238    conversation_id: Uuid,
239) -> ModelResult<Vec<ChatbotConversationMessageToolCall>> {
240    let res = sqlx::query_as!(
241        ChatbotConversationMessageToolCall,
242        r#"
243SELECT *
244FROM chatbot_conversation_message_tool_calls AS ccmtc
245WHERE ccmtc.chatbot_conversation_message_id IN (
246    SELECT id
247    FROM chatbot_conversation_messages
248    WHERE conversation_id = $1
249      AND deleted_at IS NULL
250  )
251  AND ccmtc.deleted_at IS NULL
252  AND NOT EXISTS (
253    SELECT ccmto.id
254    FROM chatbot_conversation_message_tool_outputs AS ccmto
255      JOIN chatbot_conversation_messages AS ccm ON ccm.id = ccmto.chatbot_conversation_message_id
256    WHERE ccmto.tool_call_id = ccmtc.tool_call_id
257      AND ccm.conversation_id = $1
258      AND ccmto.deleted_at IS NULL
259      AND ccm.deleted_at IS NULL
260  )
261        "#,
262        conversation_id
263    )
264    .fetch_all(conn)
265    .await?;
266    Ok(res)
267}
268
269/// Locks the call row for the duration of the surrounding transaction and refuses to hand it over
270/// for execution if it already has an output.
271///
272/// This is the exactly-once guard for a confirmable action: two concurrent confirms of the same
273/// call serialize on `FOR UPDATE`, and the loser sees the output that the winner just inserted and
274/// errors here instead of running the mutation a second time. Errors with
275/// [ModelErrorType::InvalidRequest] when an output already exists, and with
276/// [ModelErrorType::RecordNotFound] when the call itself does not.
277pub async fn lock_unanswered_for_execution(
278    conn: &mut PgConnection,
279    tool_call_id: Uuid,
280) -> ModelResult<()> {
281    let call = sqlx::query!(
282        r#"
283SELECT ccmtc.id, ccmtc.tool_call_id, ccm.conversation_id
284FROM chatbot_conversation_message_tool_calls AS ccmtc
285  JOIN chatbot_conversation_messages AS ccm ON ccm.id = ccmtc.chatbot_conversation_message_id
286WHERE ccmtc.id = $1
287  AND ccmtc.deleted_at IS NULL
288FOR UPDATE OF ccmtc
289        "#,
290        tool_call_id
291    )
292    .fetch_optional(&mut *conn)
293    .await?
294    .ok_or_else(|| {
295        model_err!(
296            RecordNotFound,
297            format!("No tool call {tool_call_id} to execute")
298        )
299    })?;
300
301    // tool_call_id is a provider string, not unique across conversations, so the check must be
302    // scoped to this call's conversation.
303    let already_answered = sqlx::query!(
304        r#"
305SELECT ccmto.id
306FROM chatbot_conversation_message_tool_outputs AS ccmto
307  JOIN chatbot_conversation_messages AS ccm ON ccm.id = ccmto.chatbot_conversation_message_id
308WHERE ccmto.tool_call_id = $1
309  AND ccm.conversation_id = $2
310  AND ccmto.deleted_at IS NULL
311  AND ccm.deleted_at IS NULL
312        "#,
313        call.tool_call_id,
314        call.conversation_id
315    )
316    .fetch_optional(conn)
317    .await?
318    .is_some();
319
320    if already_answered {
321        return Err(model_err!(
322            InvalidRequest,
323            format!("Tool call {tool_call_id} has already been answered")
324        ));
325    }
326    Ok(())
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    /// Validating a tool answer means parsing the arguments the call was recorded with, and a call
334    /// is recorded with the argument text rather than with the object it parses into.
335    #[test]
336    fn stored_arguments_come_back_as_the_json_text_the_model_wrote() {
337        let recorded = ChatbotConversationMessageToolCall {
338            tool_arguments: Value::String(r#"{"choices":["a","b"]}"#.to_string()),
339            ..Default::default()
340        };
341        assert_eq!(recorded.arguments_json(), r#"{"choices":["a","b"]}"#);
342
343        let as_an_object = ChatbotConversationMessageToolCall {
344            tool_arguments: serde_json::json!({ "choices": ["a", "b"] }),
345            ..Default::default()
346        };
347        assert_eq!(as_an_object.arguments_json(), r#"{"choices":["a","b"]}"#);
348    }
349}