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
97/// A message in a conversation.
98///
99/// Different message types have different fields:
100/// - User/System: content with optional attachments
101/// - Assistant: content with optional tool calls
102/// - Tool: content with required `tool_call_id`
103#[derive(Debug, Clone, PartialEq, Eq)]
104#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
105#[cfg_attr(feature = "serde", serde(tag = "role", rename_all = "snake_case"))]
106pub enum Message {
107    /// User message with content and optional attachments.
108    User {
109        /// Text content of the message.
110        content: String,
111        /// Attachment URLs (images, documents, etc.)
112        #[cfg_attr(
113            feature = "serde",
114            serde(default, skip_serializing_if = "Vec::is_empty")
115        )]
116        attachments: Vec<Attachment>,
117    },
118    /// Assistant message with content and optional tool calls.
119    Assistant {
120        /// Text content of the message.
121        content: String,
122        /// Tool calls made by the assistant.
123        #[cfg_attr(
124            feature = "serde",
125            serde(default, skip_serializing_if = "Vec::is_empty")
126        )]
127        tool_calls: Vec<ToolCall>,
128        /// Opaque reasoning state produced with this turn.
129        ///
130        /// Replayed to the provider on the next request. Order is significant —
131        /// Anthropic rejects a turn whose thinking blocks were reordered or
132        /// partially dropped — so append rather than rebuild.
133        #[cfg_attr(
134            feature = "serde",
135            serde(default, skip_serializing_if = "Vec::is_empty")
136        )]
137        reasoning: Vec<ReasoningState>,
138    },
139    /// System message with instructions/context.
140    System {
141        /// Text content of the message.
142        content: String,
143    },
144    /// Tool result message.
145    Tool {
146        /// Result content from the tool.
147        content: String,
148        /// ID of the tool call this is responding to.
149        tool_call_id: String,
150    },
151}
152
153impl Message {
154    /// Returns the message sender role.
155    #[must_use]
156    pub const fn role(&self) -> Role {
157        match self {
158            Self::User { .. } => Role::User,
159            Self::Assistant { .. } => Role::Assistant,
160            Self::System { .. } => Role::System,
161            Self::Tool { .. } => Role::Tool,
162        }
163    }
164
165    /// Returns the text content of the message.
166    #[must_use]
167    pub fn content(&self) -> &str {
168        match self {
169            Self::User { content, .. }
170            | Self::Assistant { content, .. }
171            | Self::System { content }
172            | Self::Tool { content, .. } => content,
173        }
174    }
175
176    /// Returns the typed attachments (only for User messages).
177    #[must_use]
178    pub fn attachments(&self) -> &[Attachment] {
179        match self {
180            Self::User { attachments, .. } => attachments,
181            _ => &[],
182        }
183    }
184
185    /// Returns tool calls made by the assistant (only for Assistant messages).
186    #[must_use]
187    pub fn tool_calls(&self) -> &[ToolCall] {
188        match self {
189            Self::Assistant { tool_calls, .. } => tool_calls,
190            _ => &[],
191        }
192    }
193
194    /// Returns the tool call ID (only for Tool messages).
195    #[must_use]
196    pub fn tool_call_id(&self) -> Option<&str> {
197        match self {
198            Self::Tool { tool_call_id, .. } => Some(tool_call_id),
199            _ => None,
200        }
201    }
202
203    /// Creates a new user message.
204    pub fn user(content: impl Into<String>) -> Self {
205        Self::User {
206            content: content.into(),
207            attachments: Vec::new(),
208        }
209    }
210
211    /// Creates a new assistant message.
212    pub fn assistant(content: impl Into<String>) -> Self {
213        Self::Assistant {
214            content: content.into(),
215            tool_calls: Vec::new(),
216            reasoning: Vec::new(),
217        }
218    }
219
220    /// Creates an assistant message with tool calls.
221    pub fn assistant_with_tool_calls(
222        content: impl Into<String>,
223        tool_calls: Vec<ToolCall>,
224    ) -> Self {
225        Self::Assistant {
226            content: content.into(),
227            tool_calls,
228            reasoning: Vec::new(),
229        }
230    }
231
232    /// Creates an assistant message carrying the reasoning state of its turn.
233    ///
234    /// Use this when rebuilding a conversation for another request: without the
235    /// reasoning, providers that verify their own thinking see a turn that has
236    /// lost the reasoning behind its tool calls.
237    pub fn assistant_with_reasoning(
238        content: impl Into<String>,
239        tool_calls: Vec<ToolCall>,
240        reasoning: Vec<ReasoningState>,
241    ) -> Self {
242        Self::Assistant {
243            content: content.into(),
244            tool_calls,
245            reasoning,
246        }
247    }
248
249    /// Reasoning state recorded on this message, if any.
250    ///
251    /// Only assistant turns carry reasoning; every other role returns empty.
252    #[must_use]
253    pub fn reasoning(&self) -> &[ReasoningState] {
254        match self {
255            Self::Assistant { reasoning, .. } => reasoning,
256            _ => &[],
257        }
258    }
259
260    /// Creates a new system message.
261    pub fn system(content: impl Into<String>) -> Self {
262        Self::System {
263            content: content.into(),
264        }
265    }
266
267    /// Creates a new tool result message.
268    pub fn tool(tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
269        Self::Tool {
270            content: content.into(),
271            tool_call_id: tool_call_id.into(),
272        }
273    }
274
275    /// Adds a typed attachment to the message (only works for User messages).
276    #[must_use]
277    pub fn with_attachment(mut self, attachment: Attachment) -> Self {
278        if let Self::User { attachments, .. } = &mut self {
279            attachments.push(attachment);
280        }
281        self
282    }
283
284    /// Adds multiple typed attachments to the message.
285    #[must_use]
286    pub fn with_attachments(mut self, values: impl IntoIterator<Item = Attachment>) -> Self {
287        if let Self::User { attachments, .. } = &mut self {
288            attachments.extend(values);
289        }
290        self
291    }
292
293    /// Adds tool calls to the message (only works for Assistant messages).
294    #[must_use]
295    pub fn with_tool_calls(mut self, calls: Vec<ToolCall>) -> Self {
296        if let Self::Assistant { tool_calls, .. } = &mut self {
297            *tool_calls = calls;
298        }
299        self
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use alloc::vec;
306
307    use super::*;
308
309    #[test]
310    fn role_equality() {
311        assert_eq!(Role::User, Role::User);
312        assert_eq!(Role::Assistant, Role::Assistant);
313        assert_eq!(Role::System, Role::System);
314        assert_eq!(Role::Tool, Role::Tool);
315        assert_ne!(Role::User, Role::Assistant);
316    }
317
318    #[test]
319    fn message_creation() {
320        let user = Message::user("Hello");
321        assert_eq!(user.role(), Role::User);
322        assert_eq!(user.content(), "Hello");
323
324        let assistant = Message::assistant("Hi there!");
325        assert_eq!(assistant.role(), Role::Assistant);
326        assert_eq!(assistant.content(), "Hi there!");
327
328        let system = Message::system("Be helpful");
329        assert_eq!(system.role(), Role::System);
330        assert_eq!(system.content(), "Be helpful");
331
332        let tool = Message::tool("call_123", "Success");
333        assert_eq!(tool.role(), Role::Tool);
334        assert_eq!(tool.content(), "Success");
335        assert_eq!(tool.tool_call_id(), Some("call_123"));
336    }
337
338    #[test]
339    fn assistant_with_tool_calls() {
340        let tool_calls = vec![ToolCall::new(
341            "call_1",
342            "get_weather",
343            serde_json::json!({"city": "NYC"}),
344        )];
345
346        let msg = Message::assistant_with_tool_calls("", tool_calls);
347        assert_eq!(msg.tool_calls().len(), 1);
348        assert_eq!(msg.tool_calls()[0].name, "get_weather");
349    }
350
351    #[test]
352    fn message_with_attachment() {
353        let attachment = Attachment::new(
354            "https://example.com/image.png".parse::<Url>().unwrap(),
355            mime::IMAGE_PNG,
356        );
357        let message = Message::user("Hello").with_attachment(attachment.clone());
358        assert_eq!(message.attachments(), &[attachment]);
359    }
360
361    #[test]
362    fn message_with_attachments() {
363        let attachments = vec![
364            Attachment::new(
365                "https://example.com/a.png".parse::<Url>().unwrap(),
366                mime::IMAGE_PNG,
367            ),
368            Attachment::new(
369                "https://example.com/b.pdf".parse::<Url>().unwrap(),
370                mime::APPLICATION_PDF,
371            ),
372        ];
373        let message = Message::user("Hello").with_attachments(attachments.clone());
374        assert_eq!(message.attachments(), attachments.as_slice());
375    }
376
377    #[test]
378    fn attachments_are_ignored_for_non_user_messages() {
379        let attachment = Attachment::new(
380            "https://example.com/a.png".parse::<Url>().unwrap(),
381            mime::IMAGE_PNG,
382        );
383        let message = Message::assistant("Hello").with_attachment(attachment);
384        assert!(
385            message.attachments().is_empty(),
386            "expected no attachments, got {:?}",
387            message.attachments()
388        );
389    }
390
391    #[test]
392    fn message_clone() {
393        let original = Message::user("Original");
394        let cloned = original.clone();
395        assert_eq!(original.content(), cloned.content());
396    }
397}