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    /// Tool calls requested by the assistant (only on `assistant` messages).
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub tool_calls: Option<Vec<ToolCall>>,
128    /// ID of the tool call this is a result for (only on `tool` messages).
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub tool_call_id: Option<String>,
131    /// Multimodal content parts (images, documents, audio). When present, `content` is also included as a text part.
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub parts: Option<Vec<ContentPart>>,
134    /// Timestamp when the message was created.
135    #[serde(default = "chrono::Utc::now")]
136    pub timestamp: DateTime<Utc>,
137}
138
139impl Message {
140    fn new(role: Role, content: String) -> Self {
141        Self {
142            role,
143            content,
144            reasoning: None,
145            tool_calls: None,
146            tool_call_id: None,
147            parts: None,
148            timestamp: Utc::now(),
149        }
150    }
151
152    /// Create a plain-text user message.
153    pub fn user(content: impl Into<String>) -> Self {
154        Self::new(Role::User, content.into())
155    }
156
157    /// Create an assistant text message (no tool calls).
158    pub fn assistant(content: impl Into<String>) -> Self {
159        Self::new(Role::Assistant, content.into())
160    }
161
162    /// Create an assistant message that includes tool call requests.
163    pub fn assistant_with_tool_calls(
164        content: impl Into<String>,
165        tool_calls: Vec<ToolCall>,
166    ) -> Self {
167        Self {
168            tool_calls: Some(tool_calls),
169            ..Self::new(Role::Assistant, content.into())
170        }
171    }
172
173    /// Create a system prompt message.
174    pub fn system(content: impl Into<String>) -> Self {
175        Self::new(Role::System, content.into())
176    }
177
178    /// Create a developer message (system-level instructions using the `developer` role).
179    pub fn developer(content: impl Into<String>) -> Self {
180        Self::new(Role::Developer, content.into())
181    }
182
183    /// Create a tool-result message linked to a prior `ToolCall.id`.
184    pub fn tool_result(tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
185        Self {
186            tool_call_id: Some(tool_call_id.into()),
187            ..Self::new(Role::Tool, content.into())
188        }
189    }
190
191    /// Create a multimodal user message with inline attachments.
192    pub fn user_with_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> Self {
193        Self {
194            parts: if parts.is_empty() { None } else { Some(parts) },
195            ..Self::new(Role::User, content.into())
196        }
197    }
198
199    /// Replace binary content parts (image, document, video, audio) with
200    /// text placeholders so the message can be sent to models that don't
201    /// support those modalities.
202    ///
203    /// Example:
204    /// - `Image { media_type: "image/jpeg", .. }` → `Text { text: "[Image: image/jpeg]" }`
205    pub fn strip_binary_data(&self) -> Self {
206        let parts = self.parts.as_ref().map(|parts| {
207            parts
208                .iter()
209                .map(|p| match p {
210                    ContentPart::Image { media_type, .. } => ContentPart::Text {
211                        text: format!("[Image: {}]", media_type),
212                    },
213                    ContentPart::ImageUrl { url } => ContentPart::Text {
214                        text: format!("[Image: {}]", url),
215                    },
216                    ContentPart::Document {
217                        media_type,
218                        filename,
219                        ..
220                    } => ContentPart::Text {
221                        text: format!("[Document: {} ({})]", filename, media_type),
222                    },
223                    ContentPart::Video {
224                        media_type,
225                        filename,
226                        ..
227                    } => ContentPart::Text {
228                        text: format!("[Video: {} ({})]", filename, media_type),
229                    },
230                    ContentPart::VideoUrl { url } => ContentPart::Text {
231                        text: format!("[Video: {}]", url),
232                    },
233                    ContentPart::Audio {
234                        media_type,
235                        filename,
236                        ..
237                    } => ContentPart::Text {
238                        text: format!("[Audio: {} ({})]", filename, media_type),
239                    },
240                    other => other.clone(),
241                })
242                .collect()
243        });
244
245        Self {
246            parts,
247            ..self.clone()
248        }
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn test_message_serialization() {
258        let msg = Message::user("Hello");
259        let json = serde_json::to_string(&msg).unwrap();
260        assert!(json.contains("\"role\":\"user\""));
261        assert!(json.contains("Hello"));
262    }
263
264    #[test]
265    fn test_message_constructors() {
266        let user = Message::user("hi");
267        assert_eq!(user.role, Role::User);
268        assert_eq!(user.content, "hi");
269
270        let assistant = Message::assistant("hello");
271        assert_eq!(assistant.role, Role::Assistant);
272
273        let system = Message::system("you are helpful");
274        assert_eq!(system.role, Role::System);
275
276        let developer = Message::developer("injected context");
277        assert_eq!(developer.role, Role::Developer);
278        assert_eq!(developer.content, "injected context");
279
280        let tool = Message::tool_result("call-1", "result data");
281        assert_eq!(tool.role, Role::Tool);
282        assert_eq!(tool.tool_call_id.as_deref(), Some("call-1"));
283    }
284
285    #[test]
286    fn test_content_part_serde() {
287        let text = ContentPart::Text {
288            text: "hello".into(),
289        };
290        let json = serde_json::to_string(&text).unwrap();
291        assert!(json.contains("\"type\":\"text\""));
292        let roundtrip: ContentPart = serde_json::from_str(&json).unwrap();
293        match roundtrip {
294            ContentPart::Text { text } => assert_eq!(text, "hello"),
295            _ => panic!("expected text"),
296        }
297
298        let img = ContentPart::Image {
299            media_type: "image/jpeg".into(),
300            data: "abc123".into(),
301        };
302        let json = serde_json::to_string(&img).unwrap();
303        assert!(json.contains("\"type\":\"image\""));
304    }
305
306    #[test]
307    fn test_strip_binary_data() {
308        let msg = Message::user_with_parts(
309            "photo",
310            vec![
311                ContentPart::Text {
312                    text: "a cat".into(),
313                },
314                ContentPart::Image {
315                    media_type: "image/jpeg".into(),
316                    data: "bigdata".into(),
317                },
318            ],
319        );
320        let stripped = msg.strip_binary_data();
321        let parts = stripped.parts.unwrap();
322        assert_eq!(parts.len(), 2);
323        match &parts[1] {
324            ContentPart::Text { text } => assert!(text.contains("image/jpeg")),
325            _ => panic!("expected text placeholder for image"),
326        }
327    }
328
329    #[test]
330    fn test_role_serde() {
331        let role = Role::Assistant;
332        let json = serde_json::to_string(&role).unwrap();
333        assert_eq!(json, "\"assistant\"");
334
335        let parsed: Role = serde_json::from_str("\"tool\"").unwrap();
336        assert_eq!(parsed, Role::Tool);
337    }
338}