Skip to main content

brainwires_core/
message.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4/// Role of the message sender
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
6#[serde(rename_all = "lowercase")]
7pub enum Role {
8    /// Message from the user.
9    User,
10    /// Message from the AI assistant.
11    Assistant,
12    /// System prompt or instruction.
13    System,
14    /// Tool result message.
15    Tool,
16}
17
18/// Message content can be simple text or complex structured content
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(untagged)]
21pub enum MessageContent {
22    /// Simple text content
23    Text(String),
24    /// Array of content blocks (for multimodal messages)
25    Blocks(Vec<ContentBlock>),
26}
27
28/// Content block for structured messages
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(tag = "type", rename_all = "snake_case")]
31pub enum ContentBlock {
32    /// Text content block.
33    Text {
34        /// The text content.
35        text: String,
36    },
37    /// Image content block (base64 encoded).
38    Image {
39        /// The image source data.
40        source: ImageSource,
41    },
42    /// Tool use request.
43    ToolUse {
44        /// Unique identifier for this tool invocation.
45        id: String,
46        /// Name of the tool to call.
47        name: String,
48        /// Input arguments for the tool.
49        input: Value,
50    },
51    /// Tool result.
52    ToolResult {
53        /// ID of the tool use this result corresponds to.
54        tool_use_id: String,
55        /// Result content.
56        content: String,
57        /// Whether this result represents an error.
58        #[serde(skip_serializing_if = "Option::is_none")]
59        is_error: Option<bool>,
60    },
61}
62
63/// Image source for image content blocks
64#[derive(Debug, Clone, Serialize, Deserialize)]
65#[serde(tag = "type", rename_all = "snake_case")]
66pub enum ImageSource {
67    /// Base64-encoded image data.
68    Base64 {
69        /// MIME type (e.g. "image/png").
70        media_type: String,
71        /// Base64-encoded image data.
72        data: String,
73    },
74}
75
76/// A message in the conversation
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct Message {
79    /// Role of the message sender
80    pub role: Role,
81    /// Content of the message
82    pub content: MessageContent,
83    /// Optional name for the message sender (useful for multi-agent conversations)
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub name: Option<String>,
86    /// Optional metadata
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub metadata: Option<Value>,
89}
90
91impl Message {
92    /// Create a new user message
93    pub fn user<S: Into<String>>(content: S) -> Self {
94        Self {
95            role: Role::User,
96            content: MessageContent::Text(content.into()),
97            name: None,
98            metadata: None,
99        }
100    }
101
102    /// Create a new assistant message
103    pub fn assistant<S: Into<String>>(content: S) -> Self {
104        Self {
105            role: Role::Assistant,
106            content: MessageContent::Text(content.into()),
107            name: None,
108            metadata: None,
109        }
110    }
111
112    /// Create a new system message
113    pub fn system<S: Into<String>>(content: S) -> Self {
114        Self {
115            role: Role::System,
116            content: MessageContent::Text(content.into()),
117            name: None,
118            metadata: None,
119        }
120    }
121
122    /// Create a tool result message
123    pub fn tool_result<S: Into<String>>(tool_use_id: S, content: S) -> Self {
124        Self {
125            role: Role::Tool,
126            content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
127                tool_use_id: tool_use_id.into(),
128                content: content.into(),
129                is_error: None,
130            }]),
131            name: None,
132            metadata: None,
133        }
134    }
135
136    /// Get the text content of a message (if it's simple text)
137    pub fn text(&self) -> Option<&str> {
138        match &self.content {
139            MessageContent::Text(text) => Some(text),
140            MessageContent::Blocks(_) => None,
141        }
142    }
143
144    /// Get a text representation of the message content, including Blocks.
145    /// For Text messages, returns the text directly.
146    /// For Blocks messages, concatenates text from all blocks into a readable summary
147    /// so that conversation history is preserved when serializing for API calls.
148    pub fn text_or_summary(&self) -> String {
149        match &self.content {
150            MessageContent::Text(text) => text.clone(),
151            MessageContent::Blocks(blocks) => {
152                let mut parts = Vec::new();
153                for block in blocks {
154                    match block {
155                        ContentBlock::Text { text } => {
156                            parts.push(text.clone());
157                        }
158                        ContentBlock::ToolUse { name, input, .. } => {
159                            parts.push(format!("[Called tool: {} with args: {}]", name, input));
160                        }
161                        ContentBlock::ToolResult {
162                            content, is_error, ..
163                        } => {
164                            if is_error == &Some(true) {
165                                parts.push(format!("[Tool error: {}]", content));
166                            } else {
167                                parts.push(format!("[Tool result: {}]", content));
168                            }
169                        }
170                        ContentBlock::Image { .. } => {
171                            parts.push("[Image]".to_string());
172                        }
173                    }
174                }
175                parts.join("\n")
176            }
177        }
178    }
179
180    /// Get mutable reference to the text content
181    pub fn text_mut(&mut self) -> Option<&mut String> {
182        match &mut self.content {
183            MessageContent::Text(text) => Some(text),
184            MessageContent::Blocks(_) => None,
185        }
186    }
187}
188
189/// Usage statistics for a chat completion
190#[derive(Debug, Clone, Serialize, Deserialize, Default)]
191pub struct Usage {
192    /// Number of tokens in the prompt
193    pub prompt_tokens: u32,
194    /// Number of tokens in the completion
195    pub completion_tokens: u32,
196    /// Total number of tokens
197    pub total_tokens: u32,
198}
199
200impl Usage {
201    /// Create a new usage statistics
202    pub fn new(prompt_tokens: u32, completion_tokens: u32) -> Self {
203        Self {
204            prompt_tokens,
205            completion_tokens,
206            total_tokens: prompt_tokens + completion_tokens,
207        }
208    }
209}
210
211/// Response from a chat completion
212#[derive(Debug, Clone)]
213pub struct ChatResponse {
214    /// The generated message
215    pub message: Message,
216    /// Usage statistics
217    pub usage: Usage,
218    /// Optional finish reason
219    pub finish_reason: Option<String>,
220}
221
222/// Serialize a slice of Messages into the STATELESS protocol format for conversation history.
223///
224/// Properly handles all message content types:
225/// - `MessageContent::Text` → `{ "role": "user"|"assistant", "content": "..." }`
226/// - `ContentBlock::ToolUse` → `{ "role": "function_call", "call_id", "name", "arguments" }`
227/// - `ContentBlock::ToolResult` → `{ "role": "tool", "call_id", "content" }`
228/// - `ContentBlock::Text` within Blocks → flushed as user/assistant text
229/// - System messages and empty assistant messages are skipped
230pub fn serialize_messages_to_stateless_history(messages: &[Message]) -> Vec<Value> {
231    let mut history = Vec::new();
232
233    for msg in messages {
234        // Skip system messages
235        if msg.role == Role::System {
236            continue;
237        }
238
239        let role_str = match msg.role {
240            Role::User => "user",
241            Role::Assistant => "assistant",
242            Role::Tool => "tool",
243            Role::System => continue, // already handled above
244        };
245
246        match &msg.content {
247            MessageContent::Text(text) => {
248                // Skip empty assistant messages
249                if msg.role == Role::Assistant && text.trim().is_empty() {
250                    continue;
251                }
252                history.push(serde_json::json!({
253                    "role": role_str,
254                    "content": text,
255                }));
256            }
257            MessageContent::Blocks(blocks) => {
258                // Accumulate text blocks, emit tool entries individually
259                let mut text_parts = Vec::new();
260
261                for block in blocks {
262                    match block {
263                        ContentBlock::Text { text } => {
264                            text_parts.push(text.clone());
265                        }
266                        ContentBlock::ToolUse { id, name, input } => {
267                            // Flush accumulated text before tool entry
268                            if !text_parts.is_empty() {
269                                let combined = text_parts.join("\n");
270                                if !(msg.role == Role::Assistant && combined.trim().is_empty()) {
271                                    history.push(serde_json::json!({
272                                        "role": role_str,
273                                        "content": combined,
274                                    }));
275                                }
276                                text_parts.clear();
277                            }
278                            history.push(serde_json::json!({
279                                "role": "function_call",
280                                "call_id": id,
281                                "name": name,
282                                "arguments": input.to_string(),
283                            }));
284                        }
285                        ContentBlock::ToolResult {
286                            tool_use_id,
287                            content,
288                            ..
289                        } => {
290                            // Flush accumulated text before tool entry
291                            if !text_parts.is_empty() {
292                                let combined = text_parts.join("\n");
293                                if !(msg.role == Role::Assistant && combined.trim().is_empty()) {
294                                    history.push(serde_json::json!({
295                                        "role": role_str,
296                                        "content": combined,
297                                    }));
298                                }
299                                text_parts.clear();
300                            }
301                            history.push(serde_json::json!({
302                                "role": "tool",
303                                "call_id": tool_use_id,
304                                "content": content,
305                            }));
306                        }
307                        ContentBlock::Image { .. } => {
308                            // Images can't be serialized to stateless text format; skip
309                        }
310                    }
311                }
312
313                // Flush any remaining text
314                if !text_parts.is_empty() {
315                    let combined = text_parts.join("\n");
316                    if !(msg.role == Role::Assistant && combined.trim().is_empty()) {
317                        history.push(serde_json::json!({
318                            "role": role_str,
319                            "content": combined,
320                        }));
321                    }
322                }
323            }
324        }
325    }
326
327    history
328}
329
330/// Streaming chunk from a chat completion
331#[derive(Debug, Clone)]
332pub enum StreamChunk {
333    /// Text delta
334    Text(String),
335    /// Tool use started.
336    ToolUse {
337        /// Unique tool use identifier.
338        id: String,
339        /// Name of the tool being invoked.
340        name: String,
341    },
342    /// Tool input delta (partial JSON streaming).
343    ToolInputDelta {
344        /// Tool use identifier this delta belongs to.
345        id: String,
346        /// Partial JSON fragment.
347        partial_json: String,
348    },
349    /// Tool call request from backend (for client-side execution).
350    ToolCall {
351        /// Unique call identifier.
352        call_id: String,
353        /// Response identifier for correlating results.
354        response_id: String,
355        /// Chat session identifier, if any.
356        chat_id: Option<String>,
357        /// Name of the tool to execute.
358        tool_name: String,
359        /// MCP server name hosting the tool.
360        server: String,
361        /// Parameters for the tool call.
362        parameters: serde_json::Value,
363    },
364    /// Usage statistics (usually sent at the end)
365    Usage(Usage),
366    /// Stream completed
367    Done,
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use serde_json::json;
374
375    #[test]
376    fn test_message_user() {
377        let msg = Message::user("Hello");
378        assert_eq!(msg.role, Role::User);
379        assert_eq!(msg.text(), Some("Hello"));
380    }
381
382    #[test]
383    fn test_message_assistant() {
384        let msg = Message::assistant("Response");
385        assert_eq!(msg.role, Role::Assistant);
386        assert_eq!(msg.text(), Some("Response"));
387    }
388
389    #[test]
390    fn test_message_tool_result() {
391        let msg = Message::tool_result("tool-1", "Result");
392        assert_eq!(msg.role, Role::Tool);
393    }
394
395    #[test]
396    fn test_usage_new() {
397        let usage = Usage::new(100, 50);
398        assert_eq!(usage.total_tokens, 150);
399    }
400
401    #[test]
402    fn test_role_serialization() {
403        let role = Role::User;
404        let json = serde_json::to_string(&role).unwrap();
405        assert_eq!(json, "\"user\"");
406    }
407
408    #[test]
409    fn test_stateless_history_simple_text() {
410        let messages = vec![Message::user("Hello"), Message::assistant("Hi there")];
411        let history = serialize_messages_to_stateless_history(&messages);
412        assert_eq!(history.len(), 2);
413        assert_eq!(history[0]["role"], "user");
414        assert_eq!(history[1]["role"], "assistant");
415    }
416
417    #[test]
418    fn test_stateless_history_skips_system() {
419        let messages = vec![Message::system("You are helpful"), Message::user("Hello")];
420        let history = serialize_messages_to_stateless_history(&messages);
421        assert_eq!(history.len(), 1);
422        assert_eq!(history[0]["role"], "user");
423    }
424
425    #[test]
426    fn test_stateless_history_tool_round_trip() {
427        let messages = vec![
428            Message::user("Read the file"),
429            Message {
430                role: Role::Assistant,
431                content: MessageContent::Blocks(vec![
432                    ContentBlock::Text {
433                        text: "I'll check.".to_string(),
434                    },
435                    ContentBlock::ToolUse {
436                        id: "call-1".to_string(),
437                        name: "read_file".to_string(),
438                        input: json!({"path": "main.rs"}),
439                    },
440                ]),
441                name: None,
442                metadata: None,
443            },
444            Message::tool_result("call-1", "fn main() {}"),
445            Message::assistant("The file contains a main function."),
446        ];
447        let history = serialize_messages_to_stateless_history(&messages);
448        assert_eq!(history.len(), 5);
449        assert_eq!(history[0]["role"], "user");
450        assert_eq!(history[1]["role"], "assistant");
451        assert_eq!(history[2]["role"], "function_call");
452        assert_eq!(history[3]["role"], "tool");
453        assert_eq!(history[4]["role"], "assistant");
454    }
455}