Skip to main content

flashmind_types/
message.rs

1//! LLM wire-format types — [`Role`], [`Message`], [`ToolCall`], and [`ContentPart`].
2//!
3//! These are the types serialized into JSON for API calls and received in responses.
4//! They are distinct from the richer intermediate representation in `flashmind-core`
5//! (`Conversation` / `ConversationEntry`).
6//!
7//! # Key types
8//!
9//! | Type | Role |
10//! |------|------|
11//! | [`Message`] | Single message in API request/response (role, content, tool calls, parts) |
12//! | [`Role`] | Message role: System, User, Assistant, Developer, Tool |
13//! | [`ToolCall`] | Tool invocation requested by the assistant |
14//! | [`ToolResult`] | Result of a tool call (returned as `role: "tool"` message) |
15//! | [`ContentPart`] | Multimodal content block: text, image, document, video, audio |
16
17use chrono::{DateTime, Utc};
18use serde::{Deserialize, Serialize};
19
20/// OpenAI-compatible message role.
21///
22/// Serialises to lowercase strings (`"system"`, `"user"`, `"assistant"`, `"developer"`, `"tool"`).
23#[derive(
24    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, strum::Display, strum::EnumString,
25)]
26#[strum(serialize_all = "lowercase")]
27#[serde(rename_all = "lowercase")]
28pub enum Role {
29    /// System-level instructions that set the model's behavior and constraints.
30    System,
31    /// Messages from the human user (questions, commands, feedback).
32    User,
33    /// Responses generated by the assistant/model.
34    Assistant,
35    /// Developer-level system messages (OpenAI-specific; hidden from the model's view in some providers).
36    Developer,
37    /// Results returned by tool execution, linked back to an assistant's [`ToolCall`] via `tool_call_id`.
38    Tool,
39}
40
41/// A tool call requested by the assistant in its response.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ToolCall {
44    /// Server-assigned ID that must be echoed back in the corresponding tool result.
45    pub id: String,
46    /// Name of the tool to invoke.
47    pub name: String,
48    /// JSON object of argument values.
49    pub arguments: serde_json::Value,
50}
51
52/// Result returned by a tool, attached as a `role: "tool"` message in the
53/// conversation history.
54///
55/// This is the wire-format representation of a tool result — the version that
56/// gets serialized into the LLM API request. It is distinct from
57/// [`crate::tool::ToolResult`], which is the richer, internal representation
58/// used during tool execution (carrying sources, diffs, and interrupt state).
59///
60/// The conversion from [`crate::tool::ToolResult`] to this type happens in the
61/// agent loop when building messages for the LLM provider.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct ToolResult {
64    /// ID of the tool call this result corresponds to. Must match the
65    /// `id` field of the original [`ToolCall`] so the LLM can pair them.
66    pub tool_call_id: String,
67    /// Text output of the tool execution. For failures this contains the
68    /// error message; for successes it contains the tool's return value.
69    pub output: String,
70    /// Whether the tool executed successfully (`true`) or failed (`false`).
71    pub success: bool,
72}
73
74/// Multimodal content block within a user message.
75///
76/// Serialised with a `type` discriminant matching the OpenAI `ContentPart` schema:
77/// `"text"`, `"image"`, `"document"`, `"video"`, `"audio"`.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79#[serde(tag = "type")]
80pub enum ContentPart {
81    #[serde(rename = "text")]
82    Text { text: String },
83    #[serde(rename = "image")]
84    Image {
85        media_type: String,
86        /// Base64-encoded image data.
87        data: String,
88    },
89    #[serde(rename = "image_url")]
90    ImageUrl { url: String },
91    #[serde(rename = "document")]
92    Document {
93        media_type: String,
94        filename: String,
95        data: String,
96    },
97    #[serde(rename = "video")]
98    Video {
99        media_type: String,
100        filename: String,
101        data: String,
102    },
103    #[serde(rename = "video_url")]
104    VideoUrl { url: String },
105    #[serde(rename = "audio")]
106    Audio {
107        media_type: String,
108        filename: String,
109        data: String,
110    },
111}
112
113/// A single message in the LLM API request / response payload.
114///
115/// For tool results set `tool_call_id` **in addition to** `content` (which holds the tool's output).
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct Message {
118    /// Message role (`system`, `user`, `assistant`, `developer`, `tool`).
119    pub role: Role,
120    /// Text content of the message.
121    pub content: String,
122    /// Reasoning retained for providers that require it on later tool turns.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub reasoning: Option<String>,
125    /// Complete provider reasoning blocks for replay on subsequent tool turns.
126    #[serde(default, skip_serializing_if = "crate::ReasoningDetails::is_empty")]
127    pub reasoning_details: crate::ReasoningDetails,
128    /// Tool calls requested by the assistant (only on `assistant` messages).
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub tool_calls: Option<Vec<ToolCall>>,
131    /// ID of the tool call this is a result for (only on `tool` messages).
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub tool_call_id: Option<String>,
134    /// Multimodal content parts (images, documents, audio). When present, `content` is also included as a text part.
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub parts: Option<Vec<ContentPart>>,
137    /// Timestamp when the message was created.
138    #[serde(default = "chrono::Utc::now")]
139    pub timestamp: DateTime<Utc>,
140}
141
142impl Message {
143    fn new(role: Role, content: String) -> Self {
144        Self {
145            role,
146            content,
147            reasoning: None,
148            reasoning_details: crate::ReasoningDetails::default(),
149            tool_calls: None,
150            tool_call_id: None,
151            parts: None,
152            timestamp: Utc::now(),
153        }
154    }
155
156    /// Create a plain-text user message.
157    pub fn user(content: impl Into<String>) -> Self {
158        Self::new(Role::User, content.into())
159    }
160
161    /// Create an assistant text message (no tool calls).
162    pub fn assistant(content: impl Into<String>) -> Self {
163        Self::new(Role::Assistant, content.into())
164    }
165
166    /// Create an assistant message that includes tool call requests.
167    pub fn assistant_with_tool_calls(
168        content: impl Into<String>,
169        tool_calls: Vec<ToolCall>,
170    ) -> Self {
171        Self {
172            tool_calls: Some(tool_calls),
173            ..Self::new(Role::Assistant, content.into())
174        }
175    }
176
177    /// Create a system prompt message.
178    pub fn system(content: impl Into<String>) -> Self {
179        Self::new(Role::System, content.into())
180    }
181
182    /// Create a developer message (system-level instructions using the `developer` role).
183    pub fn developer(content: impl Into<String>) -> Self {
184        Self::new(Role::Developer, content.into())
185    }
186
187    /// Create a tool-result message linked to a prior `ToolCall.id`.
188    pub fn tool_result(tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
189        Self {
190            tool_call_id: Some(tool_call_id.into()),
191            ..Self::new(Role::Tool, content.into())
192        }
193    }
194
195    /// Create a multimodal user message with inline attachments.
196    pub fn user_with_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> Self {
197        Self {
198            parts: if parts.is_empty() { None } else { Some(parts) },
199            ..Self::new(Role::User, content.into())
200        }
201    }
202
203    /// Replace binary content parts (image, document, video, audio) with
204    /// text placeholders so the message can be sent to models that don't
205    /// support those modalities.
206    ///
207    /// Example:
208    /// - `Image { media_type: "image/jpeg", .. }` → `Text { text: "[Image: image/jpeg]" }`
209    pub fn strip_binary_data(&self) -> Self {
210        let parts = self.parts.as_ref().map(|parts| {
211            parts
212                .iter()
213                .map(|p| match p {
214                    ContentPart::Image { media_type, .. } => ContentPart::Text {
215                        text: format!("[Image: {}]", media_type),
216                    },
217                    ContentPart::ImageUrl { url } => ContentPart::Text {
218                        text: format!("[Image: {}]", url),
219                    },
220                    ContentPart::Document {
221                        media_type,
222                        filename,
223                        ..
224                    } => ContentPart::Text {
225                        text: format!("[Document: {} ({})]", filename, media_type),
226                    },
227                    ContentPart::Video {
228                        media_type,
229                        filename,
230                        ..
231                    } => ContentPart::Text {
232                        text: format!("[Video: {} ({})]", filename, media_type),
233                    },
234                    ContentPart::VideoUrl { url } => ContentPart::Text {
235                        text: format!("[Video: {}]", url),
236                    },
237                    ContentPart::Audio {
238                        media_type,
239                        filename,
240                        ..
241                    } => ContentPart::Text {
242                        text: format!("[Audio: {} ({})]", filename, media_type),
243                    },
244                    other => other.clone(),
245                })
246                .collect()
247        });
248
249        Self {
250            parts,
251            ..self.clone()
252        }
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn test_message_serialization() {
262        let msg = Message::user("Hello");
263        let json = serde_json::to_string(&msg).unwrap();
264        assert!(json.contains("\"role\":\"user\""));
265        assert!(json.contains("Hello"));
266    }
267
268    #[test]
269    fn test_message_constructors() {
270        let user = Message::user("hi");
271        assert_eq!(user.role, Role::User);
272        assert_eq!(user.content, "hi");
273
274        let assistant = Message::assistant("hello");
275        assert_eq!(assistant.role, Role::Assistant);
276
277        let system = Message::system("you are helpful");
278        assert_eq!(system.role, Role::System);
279
280        let developer = Message::developer("injected context");
281        assert_eq!(developer.role, Role::Developer);
282        assert_eq!(developer.content, "injected context");
283
284        let tool = Message::tool_result("call-1", "result data");
285        assert_eq!(tool.role, Role::Tool);
286        assert_eq!(tool.tool_call_id.as_deref(), Some("call-1"));
287    }
288
289    #[test]
290    fn test_content_part_serde() {
291        let text = ContentPart::Text {
292            text: "hello".into(),
293        };
294        let json = serde_json::to_string(&text).unwrap();
295        assert!(json.contains("\"type\":\"text\""));
296        let roundtrip: ContentPart = serde_json::from_str(&json).unwrap();
297        match roundtrip {
298            ContentPart::Text { text } => assert_eq!(text, "hello"),
299            _ => panic!("expected text"),
300        }
301
302        let img = ContentPart::Image {
303            media_type: "image/jpeg".into(),
304            data: "abc123".into(),
305        };
306        let json = serde_json::to_string(&img).unwrap();
307        assert!(json.contains("\"type\":\"image\""));
308    }
309
310    #[test]
311    fn test_strip_binary_data() {
312        let msg = Message::user_with_parts(
313            "photo",
314            vec![
315                ContentPart::Text {
316                    text: "a cat".into(),
317                },
318                ContentPart::Image {
319                    media_type: "image/jpeg".into(),
320                    data: "bigdata".into(),
321                },
322            ],
323        );
324        let stripped = msg.strip_binary_data();
325        let parts = stripped.parts.unwrap();
326        assert_eq!(parts.len(), 2);
327        match &parts[1] {
328            ContentPart::Text { text } => assert!(text.contains("image/jpeg")),
329            _ => panic!("expected text placeholder for image"),
330        }
331    }
332
333    #[test]
334    fn test_role_serde() {
335        let role = Role::Assistant;
336        let json = serde_json::to_string(&role).unwrap();
337        assert_eq!(json, "\"assistant\"");
338
339        let parsed: Role = serde_json::from_str("\"tool\"").unwrap();
340        assert_eq!(parsed, Role::Tool);
341    }
342}