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    /// Tokens the provider charged to populate its prompt cache on this turn.
199    ///
200    /// Only meaningful for providers that support explicit caching (Anthropic
201    /// Messages API). Zero for providers without prompt caching or when the
202    /// cache is not in use.
203    #[serde(default, skip_serializing_if = "is_zero_u32")]
204    pub cache_creation_input_tokens: u32,
205    /// Tokens read from the provider's prompt cache on this turn — these are
206    /// billed at a reduced rate and are the primary cost-savings signal.
207    ///
208    /// Zero when no cached bytes were hit.
209    #[serde(default, skip_serializing_if = "is_zero_u32")]
210    pub cache_read_input_tokens: u32,
211}
212
213fn is_zero_u32(v: &u32) -> bool {
214    *v == 0
215}
216
217impl Usage {
218    /// Create a new usage statistics (no cache activity).
219    pub fn new(prompt_tokens: u32, completion_tokens: u32) -> Self {
220        Self {
221            prompt_tokens,
222            completion_tokens,
223            total_tokens: prompt_tokens + completion_tokens,
224            cache_creation_input_tokens: 0,
225            cache_read_input_tokens: 0,
226        }
227    }
228
229    /// Create a new usage statistics including cache accounting.
230    pub fn with_cache(
231        prompt_tokens: u32,
232        completion_tokens: u32,
233        cache_creation_input_tokens: u32,
234        cache_read_input_tokens: u32,
235    ) -> Self {
236        Self {
237            prompt_tokens,
238            completion_tokens,
239            total_tokens: prompt_tokens + completion_tokens,
240            cache_creation_input_tokens,
241            cache_read_input_tokens,
242        }
243    }
244}
245
246/// Response from a chat completion
247#[derive(Debug, Clone)]
248pub struct ChatResponse {
249    /// The generated message
250    pub message: Message,
251    /// Usage statistics
252    pub usage: Usage,
253    /// Optional finish reason
254    pub finish_reason: Option<String>,
255}
256
257/// Serialize a slice of Messages into the STATELESS protocol format for conversation history.
258///
259/// Properly handles all message content types:
260/// - `MessageContent::Text` → `{ "role": "user"|"assistant", "content": "..." }`
261/// - `ContentBlock::ToolUse` → `{ "role": "function_call", "call_id", "name", "arguments" }`
262/// - `ContentBlock::ToolResult` → `{ "role": "tool", "call_id", "content" }`
263/// - `ContentBlock::Text` within Blocks → flushed as user/assistant text
264/// - System messages and empty assistant messages are skipped
265pub fn serialize_messages_to_stateless_history(messages: &[Message]) -> Vec<Value> {
266    let mut history = Vec::new();
267
268    for msg in messages {
269        // Skip system messages
270        if msg.role == Role::System {
271            continue;
272        }
273
274        let role_str = match msg.role {
275            Role::User => "user",
276            Role::Assistant => "assistant",
277            Role::Tool => "tool",
278            Role::System => continue, // already handled above
279        };
280
281        match &msg.content {
282            MessageContent::Text(text) => {
283                // Skip empty assistant messages
284                if msg.role == Role::Assistant && text.trim().is_empty() {
285                    continue;
286                }
287                history.push(serde_json::json!({
288                    "role": role_str,
289                    "content": text,
290                }));
291            }
292            MessageContent::Blocks(blocks) => {
293                // Accumulate text blocks, emit tool entries individually
294                let mut text_parts = Vec::new();
295
296                for block in blocks {
297                    match block {
298                        ContentBlock::Text { text } => {
299                            text_parts.push(text.clone());
300                        }
301                        ContentBlock::ToolUse { id, name, input } => {
302                            // Flush accumulated text before tool entry
303                            if !text_parts.is_empty() {
304                                let combined = text_parts.join("\n");
305                                if !(msg.role == Role::Assistant && combined.trim().is_empty()) {
306                                    history.push(serde_json::json!({
307                                        "role": role_str,
308                                        "content": combined,
309                                    }));
310                                }
311                                text_parts.clear();
312                            }
313                            history.push(serde_json::json!({
314                                "role": "function_call",
315                                "call_id": id,
316                                "name": name,
317                                "arguments": input.to_string(),
318                            }));
319                        }
320                        ContentBlock::ToolResult {
321                            tool_use_id,
322                            content,
323                            ..
324                        } => {
325                            // Flush accumulated text before tool entry
326                            if !text_parts.is_empty() {
327                                let combined = text_parts.join("\n");
328                                if !(msg.role == Role::Assistant && combined.trim().is_empty()) {
329                                    history.push(serde_json::json!({
330                                        "role": role_str,
331                                        "content": combined,
332                                    }));
333                                }
334                                text_parts.clear();
335                            }
336                            history.push(serde_json::json!({
337                                "role": "tool",
338                                "call_id": tool_use_id,
339                                "content": content,
340                            }));
341                        }
342                        ContentBlock::Image { .. } => {
343                            // Images can't be serialized to stateless text format; skip
344                        }
345                    }
346                }
347
348                // Flush any remaining text
349                if !text_parts.is_empty() {
350                    let combined = text_parts.join("\n");
351                    if !(msg.role == Role::Assistant && combined.trim().is_empty()) {
352                        history.push(serde_json::json!({
353                            "role": role_str,
354                            "content": combined,
355                        }));
356                    }
357                }
358            }
359        }
360    }
361
362    history
363}
364
365/// Streaming chunk from a chat completion
366#[derive(Debug, Clone)]
367pub enum StreamChunk {
368    /// Text delta
369    Text(String),
370    /// Tool use started.
371    ToolUse {
372        /// Unique tool use identifier.
373        id: String,
374        /// Name of the tool being invoked.
375        name: String,
376    },
377    /// Tool input delta (partial JSON streaming).
378    ToolInputDelta {
379        /// Tool use identifier this delta belongs to.
380        id: String,
381        /// Partial JSON fragment.
382        partial_json: String,
383    },
384    /// Tool call request from backend (for client-side execution).
385    ToolCall {
386        /// Unique call identifier.
387        call_id: String,
388        /// Response identifier for correlating results.
389        response_id: String,
390        /// Chat session identifier, if any.
391        chat_id: Option<String>,
392        /// Name of the tool to execute.
393        tool_name: String,
394        /// MCP server name hosting the tool.
395        server: String,
396        /// Parameters for the tool call.
397        parameters: serde_json::Value,
398    },
399    /// Usage statistics (usually sent at the end)
400    Usage(Usage),
401    /// The model auto-compacted (summarised) the context window.
402    ///
403    /// Emitted by Claude 4.6+ when `context_window_management_event` fires.
404    /// Agents should replace their message history with a synthetic assistant
405    /// message containing the summary so future turns stay coherent.
406    ContextCompacted {
407        /// The model-generated summary that replaces the compacted messages.
408        summary: String,
409        /// Approximate number of tokens freed by compaction.
410        tokens_freed: Option<u32>,
411    },
412    /// Stream completed
413    Done,
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use serde_json::json;
420
421    #[test]
422    fn test_message_user() {
423        let msg = Message::user("Hello");
424        assert_eq!(msg.role, Role::User);
425        assert_eq!(msg.text(), Some("Hello"));
426    }
427
428    #[test]
429    fn test_message_assistant() {
430        let msg = Message::assistant("Response");
431        assert_eq!(msg.role, Role::Assistant);
432        assert_eq!(msg.text(), Some("Response"));
433    }
434
435    #[test]
436    fn test_message_tool_result() {
437        let msg = Message::tool_result("tool-1", "Result");
438        assert_eq!(msg.role, Role::Tool);
439    }
440
441    #[test]
442    fn test_usage_new() {
443        let usage = Usage::new(100, 50);
444        assert_eq!(usage.total_tokens, 150);
445    }
446
447    #[test]
448    fn test_role_serialization() {
449        let role = Role::User;
450        let json = serde_json::to_string(&role).unwrap();
451        assert_eq!(json, "\"user\"");
452    }
453
454    #[test]
455    fn test_stateless_history_simple_text() {
456        let messages = vec![Message::user("Hello"), Message::assistant("Hi there")];
457        let history = serialize_messages_to_stateless_history(&messages);
458        assert_eq!(history.len(), 2);
459        assert_eq!(history[0]["role"], "user");
460        assert_eq!(history[1]["role"], "assistant");
461    }
462
463    #[test]
464    fn test_stateless_history_skips_system() {
465        let messages = vec![Message::system("You are helpful"), Message::user("Hello")];
466        let history = serialize_messages_to_stateless_history(&messages);
467        assert_eq!(history.len(), 1);
468        assert_eq!(history[0]["role"], "user");
469    }
470
471    #[test]
472    fn test_stateless_history_tool_round_trip() {
473        let messages = vec![
474            Message::user("Read the file"),
475            Message {
476                role: Role::Assistant,
477                content: MessageContent::Blocks(vec![
478                    ContentBlock::Text {
479                        text: "I'll check.".to_string(),
480                    },
481                    ContentBlock::ToolUse {
482                        id: "call-1".to_string(),
483                        name: "read_file".to_string(),
484                        input: json!({"path": "main.rs"}),
485                    },
486                ]),
487                name: None,
488                metadata: None,
489            },
490            Message::tool_result("call-1", "fn main() {}"),
491            Message::assistant("The file contains a main function."),
492        ];
493        let history = serialize_messages_to_stateless_history(&messages);
494        assert_eq!(history.len(), 5);
495        assert_eq!(history[0]["role"], "user");
496        assert_eq!(history[1]["role"], "assistant");
497        assert_eq!(history[2]["role"], "function_call");
498        assert_eq!(history[3]["role"], "tool");
499        assert_eq!(history[4]["role"], "assistant");
500    }
501}