Skip to main content

llm_trait/
message.rs

1//! Message types for LLM conversations.
2//!
3//! These types define the conversation format shared across all LLM providers.
4
5use serde::{Deserialize, Serialize};
6
7/// A tool call embedded in an assistant message.
8#[derive(Clone, Debug, Serialize, Deserialize)]
9pub struct ToolCallMessage {
10    pub id: String,
11    pub name: String,
12    pub arguments: String,
13}
14
15/// Image attachment for multimodal messages.
16#[derive(Clone, Debug, Serialize, Deserialize)]
17pub enum ImageAttachment {
18    Url {
19        url: String,
20        #[serde(default, skip_serializing_if = "Option::is_none")]
21        detail: Option<ImageDetail>,
22    },
23    Base64 {
24        data: String,
25        #[serde(default, skip_serializing_if = "Option::is_none")]
26        media_type: Option<String>,
27        #[serde(default, skip_serializing_if = "Option::is_none")]
28        detail: Option<ImageDetail>,
29    },
30}
31
32/// Image detail level.
33#[derive(Clone, Debug, Serialize, Deserialize)]
34pub enum ImageDetail {
35    Low,
36    High,
37    Auto,
38}
39
40/// A chat message in a conversation.
41///
42/// Supports system, user, assistant, tool, and custom message types.
43/// Each variant carries the data needed for LLM API calls.
44#[derive(Clone, Debug, Serialize, Deserialize)]
45pub enum ChatMessage {
46    System {
47        content: String,
48        /// Ephemeral messages are cleaned up after each turn and skipped during persistence.
49        #[serde(default, skip_serializing)]
50        ephemeral: bool,
51    },
52    User {
53        content: String,
54        #[serde(default, skip_serializing_if = "Vec::is_empty")]
55        images: Vec<ImageAttachment>,
56        /// Ephemeral messages are cleaned up after each turn and skipped during persistence.
57        #[serde(default, skip_serializing)]
58        ephemeral: bool,
59    },
60    Assistant {
61        content: Option<String>,
62        reasoning_content: Option<String>,
63        /// Anthropic requires thinking blocks with signature to be sent back
64        /// in multi-turn conversations. This field stores the signature.
65        #[serde(default, skip_serializing_if = "Option::is_none")]
66        thinking_signature: Option<String>,
67        tool_calls: Option<Vec<ToolCallMessage>>,
68    },
69    Tool {
70        tool_call_id: String,
71        name: Option<String>,
72        content: String,
73    },
74    /// Application-defined message type for extensibility.
75    ///
76    /// Custom messages are preserved in the transcript but filtered out
77    /// by the default conversion before being sent to the LLM provider.
78    Custom {
79        role: String,
80        data: serde_json::Value,
81    },
82}
83
84impl ChatMessage {
85    pub fn system(content: impl Into<String>) -> Self {
86        Self::System {
87            content: content.into(),
88            ephemeral: false,
89        }
90    }
91
92    /// Create an ephemeral system message: auto-cleaned after turn, not persisted.
93    pub fn system_ephemeral(content: impl Into<String>) -> Self {
94        Self::System {
95            content: content.into(),
96            ephemeral: true,
97        }
98    }
99
100    pub fn user(content: impl Into<String>) -> Self {
101        Self::User {
102            content: content.into(),
103            images: Vec::new(),
104            ephemeral: false,
105        }
106    }
107
108    /// Create an ephemeral user message: auto-cleaned after turn, not persisted.
109    pub fn user_ephemeral(content: impl Into<String>) -> Self {
110        Self::User {
111            content: content.into(),
112            images: Vec::new(),
113            ephemeral: true,
114        }
115    }
116
117    pub fn user_with_images(content: impl Into<String>, images: Vec<ImageAttachment>) -> Self {
118        Self::User {
119            content: content.into(),
120            images,
121            ephemeral: false,
122        }
123    }
124
125    /// Whether this is an ephemeral message (auto-cleaned after turn, not persisted).
126    pub fn is_ephemeral(&self) -> bool {
127        match self {
128            Self::System { ephemeral, .. } => *ephemeral,
129            Self::User { ephemeral, .. } => *ephemeral,
130            _ => false,
131        }
132    }
133
134    pub fn assistant(content: impl Into<String>) -> Self {
135        Self::Assistant {
136            content: Some(content.into()),
137            reasoning_content: None,
138            thinking_signature: None,
139            tool_calls: None,
140        }
141    }
142
143    pub fn assistant_with_reasoning(
144        content: impl Into<String>,
145        reasoning: impl Into<String>,
146    ) -> Self {
147        Self::Assistant {
148            content: Some(content.into()),
149            reasoning_content: Some(reasoning.into()),
150            thinking_signature: None,
151            tool_calls: None,
152        }
153    }
154
155    pub fn assistant_tool_call(
156        tool_call_id: impl Into<String>,
157        tool_name: impl Into<String>,
158        arguments: impl Into<String>,
159    ) -> Self {
160        Self::Assistant {
161            content: None,
162            reasoning_content: None,
163            thinking_signature: None,
164            tool_calls: Some(vec![ToolCallMessage {
165                id: tool_call_id.into(),
166                name: tool_name.into(),
167                arguments: arguments.into(),
168            }]),
169        }
170    }
171
172    pub fn tool(tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
173        Self::Tool {
174            tool_call_id: tool_call_id.into(),
175            name: None,
176            content: content.into(),
177        }
178    }
179
180    pub fn tool_with_name(
181        tool_call_id: impl Into<String>,
182        name: impl Into<String>,
183        content: impl Into<String>,
184    ) -> Self {
185        Self::Tool {
186            tool_call_id: tool_call_id.into(),
187            name: Some(name.into()),
188            content: content.into(),
189        }
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn chat_message_user() {
199        let msg = ChatMessage::user("hello");
200        match &msg {
201            ChatMessage::User {
202                content,
203                images,
204                ephemeral,
205            } => {
206                assert_eq!(content, "hello");
207                assert!(images.is_empty());
208                assert!(!ephemeral);
209            }
210            _ => panic!("Expected User variant"),
211        }
212    }
213
214    #[test]
215    fn chat_message_system() {
216        let msg = ChatMessage::system("sys");
217        assert!(!msg.is_ephemeral());
218    }
219
220    #[test]
221    fn chat_message_system_ephemeral() {
222        let msg = ChatMessage::system_ephemeral("sys");
223        assert!(msg.is_ephemeral());
224    }
225
226    #[test]
227    fn chat_message_user_ephemeral() {
228        let msg = ChatMessage::user_ephemeral("hi");
229        assert!(msg.is_ephemeral());
230    }
231
232    #[test]
233    fn chat_message_assistant() {
234        let msg = ChatMessage::assistant("response");
235        match &msg {
236            ChatMessage::Assistant { content, .. } => {
237                assert_eq!(content.as_deref(), Some("response"));
238            }
239            _ => panic!("Expected Assistant variant"),
240        }
241    }
242
243    #[test]
244    fn chat_message_tool_call() {
245        let msg = ChatMessage::assistant_tool_call("id1", "echo", r#"{"x":1}"#);
246        match &msg {
247            ChatMessage::Assistant { tool_calls, .. } => {
248                let tc = tool_calls.as_ref().unwrap();
249                assert_eq!(tc.len(), 1);
250                assert_eq!(tc[0].id, "id1");
251                assert_eq!(tc[0].name, "echo");
252            }
253            _ => panic!("Expected Assistant variant"),
254        }
255    }
256
257    #[test]
258    fn chat_message_tool() {
259        let msg = ChatMessage::tool("id1", "result");
260        match &msg {
261            ChatMessage::Tool {
262                tool_call_id,
263                name,
264                content,
265            } => {
266                assert_eq!(tool_call_id, "id1");
267                assert!(name.is_none());
268                assert_eq!(content, "result");
269            }
270            _ => panic!("Expected Tool variant"),
271        }
272    }
273
274    #[test]
275    fn chat_message_tool_with_name() {
276        let msg = ChatMessage::tool_with_name("id1", "echo", "result");
277        match &msg {
278            ChatMessage::Tool {
279                tool_call_id,
280                name,
281                content,
282            } => {
283                assert_eq!(tool_call_id, "id1");
284                assert_eq!(name.as_deref(), Some("echo"));
285                assert_eq!(content, "result");
286            }
287            _ => panic!("Expected Tool variant"),
288        }
289    }
290
291    #[test]
292    fn custom_message_not_ephemeral() {
293        let msg = ChatMessage::Custom {
294            role: "artifact".to_string(),
295            data: serde_json::json!({}),
296        };
297        assert!(!msg.is_ephemeral());
298    }
299
300    #[test]
301    fn serialization_roundtrip() {
302        let msg = ChatMessage::user("hello");
303        let json = serde_json::to_string(&msg).unwrap();
304        let deserialized: ChatMessage = serde_json::from_str(&json).unwrap();
305        match deserialized {
306            ChatMessage::User { content, .. } => assert_eq!(content, "hello"),
307            _ => panic!("Expected User"),
308        }
309    }
310}