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