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