Skip to main content

aither_core/llm/
message.rs

1//! Message types for AI language model conversations.
2//!
3//! This module provides types for representing messages in conversations with AI language models.
4//! Messages are represented as an enum with variants for different roles (User, Assistant, System, Tool).
5
6use alloc::{string::String, vec::Vec};
7use mime::Mime;
8use url::Url;
9
10use super::event::ToolCall;
11use super::reasoning::ReasoningState;
12
13/// A typed media attachment supplied with a user message.
14///
15/// Keeping the MIME type beside the URL lets each provider choose the correct
16/// protocol content block without guessing from a provider-generated URL.
17#[derive(Debug, Clone, PartialEq, Eq)]
18#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
19pub struct Attachment {
20    url: Url,
21    #[cfg_attr(feature = "serde", serde(with = "mime_serde"))]
22    media_type: Mime,
23}
24
25impl Attachment {
26    /// Creates an attachment with an explicit MIME type.
27    #[must_use]
28    pub const fn new(url: Url, media_type: Mime) -> Self {
29        Self { url, media_type }
30    }
31
32    /// Returns the attachment URL.
33    #[must_use]
34    pub const fn url(&self) -> &Url {
35        &self.url
36    }
37
38    /// Returns the declared MIME type.
39    #[must_use]
40    pub const fn media_type(&self) -> &Mime {
41        &self.media_type
42    }
43
44    /// Replaces the URL while preserving the declared MIME type.
45    #[must_use]
46    pub fn with_url(self, url: Url) -> Self {
47        Self {
48            url,
49            media_type: self.media_type,
50        }
51    }
52
53    /// Splits the attachment into its URL and MIME type.
54    #[must_use]
55    pub fn into_parts(self) -> (Url, Mime) {
56        (self.url, self.media_type)
57    }
58}
59
60#[cfg(feature = "serde")]
61mod mime_serde {
62    use alloc::string::String;
63    use core::str::FromStr;
64    use mime::Mime;
65    use serde::{Deserialize, Deserializer, Serializer, de::Error as _};
66
67    pub fn serialize<S>(media_type: &Mime, serializer: S) -> Result<S::Ok, S::Error>
68    where
69        S: Serializer,
70    {
71        serializer.serialize_str(media_type.as_ref())
72    }
73
74    pub fn deserialize<'de, D>(deserializer: D) -> Result<Mime, D::Error>
75    where
76        D: Deserializer<'de>,
77    {
78        let raw = String::deserialize(deserializer)?;
79        Mime::from_str(&raw).map_err(D::Error::custom)
80    }
81}
82
83/// Conversation participant role.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
85#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
86pub enum Role {
87    /// User message - input from human user.
88    User,
89    /// AI assistant message - responses from the AI.
90    Assistant,
91    /// System message - context/instructions for the AI.
92    System,
93    /// Tool message - output from tool/function calls.
94    Tool,
95}
96
97impl core::fmt::Display for Role {
98    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
99        f.write_str(match self {
100            Self::User => "User",
101            Self::Assistant => "Assistant",
102            Self::System => "System",
103            Self::Tool => "Tool",
104        })
105    }
106}
107
108/// A message in a conversation.
109///
110/// Different message types have different fields:
111/// - User/System: content with optional attachments
112/// - Assistant: content with optional tool calls
113/// - Tool: content with required `tool_call_id`
114#[derive(Debug, Clone, PartialEq, Eq)]
115#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
116#[cfg_attr(feature = "serde", serde(tag = "role", rename_all = "snake_case"))]
117pub enum Message {
118    /// User message with content and optional attachments.
119    User {
120        /// Text content of the message.
121        content: String,
122        /// Attachment URLs (images, documents, etc.)
123        #[cfg_attr(
124            feature = "serde",
125            serde(default, skip_serializing_if = "Vec::is_empty")
126        )]
127        attachments: Vec<Attachment>,
128    },
129    /// Assistant message with content and optional tool calls.
130    Assistant {
131        /// Text content of the message.
132        content: String,
133        /// Tool calls made by the assistant.
134        #[cfg_attr(
135            feature = "serde",
136            serde(default, skip_serializing_if = "Vec::is_empty")
137        )]
138        tool_calls: Vec<ToolCall>,
139        /// Opaque reasoning state produced with this turn.
140        ///
141        /// Replayed to the provider on the next request. Order is significant —
142        /// Anthropic rejects a turn whose thinking blocks were reordered or
143        /// partially dropped — so append rather than rebuild.
144        #[cfg_attr(
145            feature = "serde",
146            serde(default, skip_serializing_if = "Vec::is_empty")
147        )]
148        reasoning: Vec<ReasoningState>,
149    },
150    /// System message with instructions/context.
151    System {
152        /// Text content of the message.
153        content: String,
154    },
155    /// Tool result message.
156    Tool {
157        /// Result content from the tool.
158        content: String,
159        /// ID of the tool call this is responding to.
160        tool_call_id: String,
161    },
162}
163
164impl Message {
165    /// Returns the message sender role.
166    #[must_use]
167    pub const fn role(&self) -> Role {
168        match self {
169            Self::User { .. } => Role::User,
170            Self::Assistant { .. } => Role::Assistant,
171            Self::System { .. } => Role::System,
172            Self::Tool { .. } => Role::Tool,
173        }
174    }
175
176    /// Returns the text content of the message.
177    #[must_use]
178    pub fn content(&self) -> &str {
179        match self {
180            Self::User { content, .. }
181            | Self::Assistant { content, .. }
182            | Self::System { content }
183            | Self::Tool { content, .. } => content,
184        }
185    }
186
187    /// Returns the typed attachments (only for User messages).
188    #[must_use]
189    pub fn attachments(&self) -> &[Attachment] {
190        match self {
191            Self::User { attachments, .. } => attachments,
192            _ => &[],
193        }
194    }
195
196    /// Returns tool calls made by the assistant (only for Assistant messages).
197    #[must_use]
198    pub fn tool_calls(&self) -> &[ToolCall] {
199        match self {
200            Self::Assistant { tool_calls, .. } => tool_calls,
201            _ => &[],
202        }
203    }
204
205    /// Returns the tool call ID (only for Tool messages).
206    #[must_use]
207    pub fn tool_call_id(&self) -> Option<&str> {
208        match self {
209            Self::Tool { tool_call_id, .. } => Some(tool_call_id),
210            _ => None,
211        }
212    }
213
214    /// Creates a new user message.
215    pub fn user(content: impl Into<String>) -> Self {
216        Self::User {
217            content: content.into(),
218            attachments: Vec::new(),
219        }
220    }
221
222    /// Creates a new assistant message.
223    pub fn assistant(content: impl Into<String>) -> Self {
224        Self::Assistant {
225            content: content.into(),
226            tool_calls: Vec::new(),
227            reasoning: Vec::new(),
228        }
229    }
230
231    /// Creates an assistant message with tool calls.
232    pub fn assistant_with_tool_calls(
233        content: impl Into<String>,
234        tool_calls: Vec<ToolCall>,
235    ) -> Self {
236        Self::Assistant {
237            content: content.into(),
238            tool_calls,
239            reasoning: Vec::new(),
240        }
241    }
242
243    /// Creates an assistant message carrying the reasoning state of its turn.
244    ///
245    /// Use this when rebuilding a conversation for another request: without the
246    /// reasoning, providers that verify their own thinking see a turn that has
247    /// lost the reasoning behind its tool calls.
248    pub fn assistant_with_reasoning(
249        content: impl Into<String>,
250        tool_calls: Vec<ToolCall>,
251        reasoning: Vec<ReasoningState>,
252    ) -> Self {
253        Self::Assistant {
254            content: content.into(),
255            tool_calls,
256            reasoning,
257        }
258    }
259
260    /// Reasoning state recorded on this message, if any.
261    ///
262    /// Only assistant turns carry reasoning; every other role returns empty.
263    #[must_use]
264    pub fn reasoning(&self) -> &[ReasoningState] {
265        match self {
266            Self::Assistant { reasoning, .. } => reasoning,
267            _ => &[],
268        }
269    }
270
271    /// Creates a new system message.
272    pub fn system(content: impl Into<String>) -> Self {
273        Self::System {
274            content: content.into(),
275        }
276    }
277
278    /// Creates a new tool result message.
279    pub fn tool(tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
280        Self::Tool {
281            content: content.into(),
282            tool_call_id: tool_call_id.into(),
283        }
284    }
285
286    /// Adds a typed attachment to the message (only works for User messages).
287    #[must_use]
288    pub fn with_attachment(mut self, attachment: Attachment) -> Self {
289        if let Self::User { attachments, .. } = &mut self {
290            attachments.push(attachment);
291        }
292        self
293    }
294
295    /// Adds multiple typed attachments to the message.
296    #[must_use]
297    pub fn with_attachments(mut self, values: impl IntoIterator<Item = Attachment>) -> Self {
298        if let Self::User { attachments, .. } = &mut self {
299            attachments.extend(values);
300        }
301        self
302    }
303
304    /// Adds tool calls to the message (only works for Assistant messages).
305    #[must_use]
306    pub fn with_tool_calls(mut self, calls: Vec<ToolCall>) -> Self {
307        if let Self::Assistant { tool_calls, .. } = &mut self {
308            *tool_calls = calls;
309        }
310        self
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use alloc::vec;
317
318    use super::*;
319
320    #[test]
321    fn role_equality() {
322        assert_eq!(Role::User, Role::User);
323        assert_eq!(Role::Assistant, Role::Assistant);
324        assert_eq!(Role::System, Role::System);
325        assert_eq!(Role::Tool, Role::Tool);
326        assert_ne!(Role::User, Role::Assistant);
327    }
328
329    #[test]
330    fn message_creation() {
331        let user = Message::user("Hello");
332        assert_eq!(user.role(), Role::User);
333        assert_eq!(user.content(), "Hello");
334
335        let assistant = Message::assistant("Hi there!");
336        assert_eq!(assistant.role(), Role::Assistant);
337        assert_eq!(assistant.content(), "Hi there!");
338
339        let system = Message::system("Be helpful");
340        assert_eq!(system.role(), Role::System);
341        assert_eq!(system.content(), "Be helpful");
342
343        let tool = Message::tool("call_123", "Success");
344        assert_eq!(tool.role(), Role::Tool);
345        assert_eq!(tool.content(), "Success");
346        assert_eq!(tool.tool_call_id(), Some("call_123"));
347    }
348
349    #[test]
350    fn assistant_with_tool_calls() {
351        let tool_calls = vec![ToolCall::new(
352            "call_1",
353            "get_weather",
354            serde_json::json!({"city": "NYC"}),
355        )];
356
357        let msg = Message::assistant_with_tool_calls("", tool_calls);
358        assert_eq!(msg.tool_calls().len(), 1);
359        assert_eq!(msg.tool_calls()[0].name, "get_weather");
360    }
361
362    #[test]
363    fn message_with_attachment() {
364        let attachment = Attachment::new(
365            "https://example.com/image.png".parse::<Url>().unwrap(),
366            mime::IMAGE_PNG,
367        );
368        let message = Message::user("Hello").with_attachment(attachment.clone());
369        assert_eq!(message.attachments(), &[attachment]);
370    }
371
372    #[test]
373    fn message_with_attachments() {
374        let attachments = vec![
375            Attachment::new(
376                "https://example.com/a.png".parse::<Url>().unwrap(),
377                mime::IMAGE_PNG,
378            ),
379            Attachment::new(
380                "https://example.com/b.pdf".parse::<Url>().unwrap(),
381                mime::APPLICATION_PDF,
382            ),
383        ];
384        let message = Message::user("Hello").with_attachments(attachments.clone());
385        assert_eq!(message.attachments(), attachments.as_slice());
386    }
387
388    #[test]
389    fn attachments_are_ignored_for_non_user_messages() {
390        let attachment = Attachment::new(
391            "https://example.com/a.png".parse::<Url>().unwrap(),
392            mime::IMAGE_PNG,
393        );
394        let message = Message::assistant("Hello").with_attachment(attachment);
395        assert!(
396            message.attachments().is_empty(),
397            "expected no attachments, got {:?}",
398            message.attachments()
399        );
400    }
401
402    #[test]
403    fn message_clone() {
404        let original = Message::user("Original");
405        let cloned = original.clone();
406        assert_eq!(original.content(), cloned.content());
407    }
408}