Skip to main content

agent_code_lib/llm/
message.rs

1//! Message types for the conversation protocol.
2//!
3//! These types mirror the wire format used by LLM APIs. The conversation
4//! is a sequence of messages with roles (system, user, assistant) and
5//! content blocks (text, tool_use, tool_result, thinking).
6
7use serde::{Deserialize, Serialize};
8use uuid::Uuid;
9
10/// A message in the conversation.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12#[serde(tag = "type")]
13pub enum Message {
14    /// User input message.
15    #[serde(rename = "user")]
16    User(UserMessage),
17    /// Assistant (model) response.
18    #[serde(rename = "assistant")]
19    Assistant(AssistantMessage),
20    /// System notification (not sent to API).
21    #[serde(rename = "system")]
22    System(SystemMessage),
23}
24
25impl Message {
26    pub fn uuid(&self) -> &Uuid {
27        match self {
28            Message::User(m) => &m.uuid,
29            Message::Assistant(m) => &m.uuid,
30            Message::System(m) => &m.uuid,
31        }
32    }
33}
34
35/// User-originated message.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct UserMessage {
38    pub uuid: Uuid,
39    pub timestamp: String,
40    pub content: Vec<ContentBlock>,
41    /// If true, this message is metadata (tool results, context injection)
42    /// rather than direct user input.
43    #[serde(default)]
44    pub is_meta: bool,
45    /// If true, this is a compact summary replacing earlier messages.
46    #[serde(default)]
47    pub is_compact_summary: bool,
48}
49
50/// Assistant response message.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct AssistantMessage {
53    pub uuid: Uuid,
54    pub timestamp: String,
55    pub content: Vec<ContentBlock>,
56    /// Model that generated this response.
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub model: Option<String>,
59    /// Token usage for this response.
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub usage: Option<Usage>,
62    /// Why the model stopped generating.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub stop_reason: Option<StopReason>,
65    /// API request ID for debugging.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub request_id: Option<String>,
68}
69
70/// System notification (informational, error, etc.).
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct SystemMessage {
73    pub uuid: Uuid,
74    pub timestamp: String,
75    pub subtype: SystemMessageType,
76    pub content: String,
77    #[serde(default)]
78    pub level: MessageLevel,
79}
80
81/// System message subtypes.
82#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
83#[serde(rename_all = "snake_case")]
84pub enum SystemMessageType {
85    Informational,
86    ApiError,
87    CompactBoundary,
88    TurnDuration,
89    MemorySaved,
90    ToolProgress,
91}
92
93/// Message severity level.
94#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
95#[serde(rename_all = "snake_case")]
96pub enum MessageLevel {
97    #[default]
98    Info,
99    Warning,
100    Error,
101}
102
103/// A block of content within a message.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105#[serde(tag = "type")]
106pub enum ContentBlock {
107    /// Plain text content.
108    #[serde(rename = "text")]
109    Text { text: String },
110
111    /// A request from the model to execute a tool.
112    #[serde(rename = "tool_use")]
113    ToolUse {
114        id: String,
115        name: String,
116        input: serde_json::Value,
117    },
118
119    /// The result of a tool execution, sent back to the model.
120    /// Content can be a simple string or an array of content blocks
121    /// (e.g., text + images for vision-enabled tool results).
122    #[serde(rename = "tool_result")]
123    ToolResult {
124        tool_use_id: String,
125        content: String,
126        #[serde(default)]
127        is_error: bool,
128        /// Optional rich content blocks (images, etc.) alongside the text.
129        #[serde(default, skip_serializing_if = "Vec::is_empty")]
130        extra_content: Vec<ToolResultBlock>,
131    },
132
133    /// Extended thinking content (model reasoning).
134    #[serde(rename = "thinking")]
135    Thinking {
136        thinking: String,
137        #[serde(skip_serializing_if = "Option::is_none")]
138        signature: Option<String>,
139    },
140
141    /// Image content.
142    #[serde(rename = "image")]
143    Image {
144        #[serde(rename = "media_type")]
145        media_type: String,
146        data: String,
147    },
148
149    /// Document content (e.g., PDF pages sent inline).
150    #[serde(rename = "document")]
151    Document {
152        #[serde(rename = "media_type")]
153        media_type: String,
154        data: String,
155        #[serde(skip_serializing_if = "Option::is_none")]
156        title: Option<String>,
157    },
158}
159
160/// A block within a rich tool result (for multi-modal tool output).
161#[derive(Debug, Clone, Serialize, Deserialize)]
162#[serde(tag = "type")]
163pub enum ToolResultBlock {
164    #[serde(rename = "text")]
165    Text { text: String },
166    #[serde(rename = "image")]
167    Image {
168        #[serde(rename = "media_type")]
169        media_type: String,
170        data: String,
171    },
172}
173
174impl ContentBlock {
175    /// Extract text content, if this is a text block.
176    pub fn as_text(&self) -> Option<&str> {
177        match self {
178            ContentBlock::Text { text } => Some(text),
179            _ => None,
180        }
181    }
182
183    /// Extract tool use info, if this is a tool_use block.
184    pub fn as_tool_use(&self) -> Option<(&str, &str, &serde_json::Value)> {
185        match self {
186            ContentBlock::ToolUse { id, name, input } => Some((id, name, input)),
187            _ => None,
188        }
189    }
190}
191
192/// Token usage information.
193#[derive(Debug, Clone, Default, Serialize, Deserialize)]
194pub struct Usage {
195    pub input_tokens: u64,
196    pub output_tokens: u64,
197    #[serde(default)]
198    pub cache_creation_input_tokens: u64,
199    #[serde(default)]
200    pub cache_read_input_tokens: u64,
201}
202
203impl Usage {
204    /// Total tokens consumed.
205    pub fn total(&self) -> u64 {
206        self.input_tokens
207            + self.output_tokens
208            + self.cache_creation_input_tokens
209            + self.cache_read_input_tokens
210    }
211
212    /// Merge usage from a subsequent response.
213    pub fn merge(&mut self, other: &Usage) {
214        self.input_tokens = other.input_tokens;
215        self.output_tokens += other.output_tokens;
216        self.cache_creation_input_tokens = other.cache_creation_input_tokens;
217        self.cache_read_input_tokens = other.cache_read_input_tokens;
218    }
219}
220
221/// Why the model stopped generating.
222#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
223#[serde(rename_all = "snake_case")]
224pub enum StopReason {
225    EndTurn,
226    MaxTokens,
227    ToolUse,
228    StopSequence,
229}
230
231/// Helper to create a user message with text content.
232pub fn user_message(text: impl Into<String>) -> Message {
233    Message::User(UserMessage {
234        uuid: Uuid::new_v4(),
235        timestamp: chrono::Utc::now().to_rfc3339(),
236        content: vec![ContentBlock::Text { text: text.into() }],
237        is_meta: false,
238        is_compact_summary: false,
239    })
240}
241
242/// Helper to create an image content block from a file path.
243///
244/// Reads the file, base64-encodes it, and infers the media type
245/// from the file extension.
246pub fn image_block_from_file(path: &std::path::Path) -> Result<ContentBlock, String> {
247    let data = std::fs::read(path).map_err(|e| format!("Failed to read image: {e}"))?;
248
249    let media_type = match path.extension().and_then(|e| e.to_str()) {
250        Some("png") => "image/png",
251        Some("jpg" | "jpeg") => "image/jpeg",
252        Some("gif") => "image/gif",
253        Some("webp") => "image/webp",
254        Some("svg") => "image/svg+xml",
255        _ => "application/octet-stream",
256    };
257
258    use std::io::Write;
259    let mut encoded = String::new();
260    {
261        let mut encoder = base64_encode_writer(&mut encoded);
262        encoder
263            .write_all(&data)
264            .map_err(|e| format!("base64 error: {e}"))?;
265    }
266
267    Ok(ContentBlock::Image {
268        media_type: media_type.to_string(),
269        data: encoded,
270    })
271}
272
273/// Simple base64 encoder (no external dependency).
274fn base64_encode_writer(output: &mut String) -> Base64Writer<'_> {
275    Base64Writer {
276        output,
277        buffer: Vec::new(),
278    }
279}
280
281struct Base64Writer<'a> {
282    output: &'a mut String,
283    buffer: Vec<u8>,
284}
285
286impl<'a> std::io::Write for Base64Writer<'a> {
287    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
288        self.buffer.extend_from_slice(buf);
289        Ok(buf.len())
290    }
291    fn flush(&mut self) -> std::io::Result<()> {
292        const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
293        let mut i = 0;
294        while i + 2 < self.buffer.len() {
295            let b0 = self.buffer[i] as usize;
296            let b1 = self.buffer[i + 1] as usize;
297            let b2 = self.buffer[i + 2] as usize;
298            self.output.push(CHARS[b0 >> 2] as char);
299            self.output.push(CHARS[((b0 & 3) << 4) | (b1 >> 4)] as char);
300            self.output
301                .push(CHARS[((b1 & 0xf) << 2) | (b2 >> 6)] as char);
302            self.output.push(CHARS[b2 & 0x3f] as char);
303            i += 3;
304        }
305        let remaining = self.buffer.len() - i;
306        if remaining == 1 {
307            let b0 = self.buffer[i] as usize;
308            self.output.push(CHARS[b0 >> 2] as char);
309            self.output.push(CHARS[(b0 & 3) << 4] as char);
310            self.output.push('=');
311            self.output.push('=');
312        } else if remaining == 2 {
313            let b0 = self.buffer[i] as usize;
314            let b1 = self.buffer[i + 1] as usize;
315            self.output.push(CHARS[b0 >> 2] as char);
316            self.output.push(CHARS[((b0 & 3) << 4) | (b1 >> 4)] as char);
317            self.output.push(CHARS[(b1 & 0xf) << 2] as char);
318            self.output.push('=');
319        }
320        Ok(())
321    }
322}
323
324/// Helper to create a user message with an image.
325pub fn image_message(path: &std::path::Path, caption: &str) -> Result<Message, String> {
326    let image = image_block_from_file(path)?;
327    Ok(Message::User(UserMessage {
328        uuid: Uuid::new_v4(),
329        timestamp: chrono::Utc::now().to_rfc3339(),
330        content: vec![
331            image,
332            ContentBlock::Text {
333                text: caption.to_string(),
334            },
335        ],
336        is_meta: false,
337        is_compact_summary: false,
338    }))
339}
340
341/// Helper to create a tool result message.
342pub fn tool_result_message(tool_use_id: &str, content: &str, is_error: bool) -> Message {
343    Message::User(UserMessage {
344        uuid: Uuid::new_v4(),
345        timestamp: chrono::Utc::now().to_rfc3339(),
346        content: vec![ContentBlock::ToolResult {
347            tool_use_id: tool_use_id.to_string(),
348            content: content.to_string(),
349            is_error,
350            extra_content: vec![],
351        }],
352        is_meta: true,
353        is_compact_summary: false,
354    })
355}
356
357/// Convert messages to the API wire format (for sending to the LLM).
358pub fn messages_to_api_params(messages: &[Message]) -> Vec<serde_json::Value> {
359    messages
360        .iter()
361        .filter_map(|msg| match msg {
362            Message::User(u) => Some(serde_json::json!({
363                "role": "user",
364                "content": content_blocks_to_api(&u.content),
365            })),
366            Message::Assistant(a) => Some(serde_json::json!({
367                "role": "assistant",
368                "content": content_blocks_to_api(&a.content),
369            })),
370            // System messages are not sent to the API.
371            Message::System(_) => None,
372        })
373        .collect()
374}
375
376fn content_blocks_to_api(blocks: &[ContentBlock]) -> serde_json::Value {
377    let api_blocks: Vec<serde_json::Value> = blocks
378        .iter()
379        .map(|block| match block {
380            ContentBlock::Text { text } => serde_json::json!({
381                "type": "text",
382                "text": text,
383            }),
384            ContentBlock::ToolUse { id, name, input } => serde_json::json!({
385                "type": "tool_use",
386                "id": id,
387                "name": name,
388                "input": input,
389            }),
390            ContentBlock::ToolResult {
391                tool_use_id,
392                content,
393                is_error,
394                ..
395            } => serde_json::json!({
396                "type": "tool_result",
397                "tool_use_id": tool_use_id,
398                "content": content,
399                "is_error": is_error,
400            }),
401            ContentBlock::Thinking {
402                thinking,
403                signature,
404            } => serde_json::json!({
405                "type": "thinking",
406                "thinking": thinking,
407                "signature": signature,
408            }),
409            ContentBlock::Image { media_type, data } => serde_json::json!({
410                "type": "image",
411                "source": {
412                    "type": "base64",
413                    "media_type": media_type,
414                    "data": data,
415                }
416            }),
417            ContentBlock::Document {
418                media_type,
419                data,
420                title,
421            } => {
422                let mut doc = serde_json::json!({
423                    "type": "document",
424                    "source": {
425                        "type": "base64",
426                        "media_type": media_type,
427                        "data": data,
428                    }
429                });
430                if let Some(t) = title {
431                    doc["title"] = serde_json::json!(t);
432                }
433                doc
434            }
435        })
436        .collect();
437
438    // If there's only one text block, use the simple string format.
439    if api_blocks.len() == 1
440        && let Some(text) = blocks[0].as_text()
441    {
442        return serde_json::Value::String(text.to_string());
443    }
444
445    serde_json::Value::Array(api_blocks)
446}
447
448/// Convert messages to API params with cache_control breakpoints.
449///
450/// Places an ephemeral cache marker on the last user message before
451/// the current turn, so the conversation prefix stays cached across
452/// the tool-call loop within a single turn.
453pub fn messages_to_api_params_cached(messages: &[Message]) -> Vec<serde_json::Value> {
454    // Find the second-to-last non-meta user message index for cache marking.
455    let user_indices: Vec<usize> = messages
456        .iter()
457        .enumerate()
458        .filter(|(_, m)| matches!(m, Message::User(u) if !u.is_meta))
459        .map(|(i, _)| i)
460        .collect();
461
462    let cache_index = if user_indices.len() >= 2 {
463        Some(user_indices[user_indices.len() - 2])
464    } else {
465        None
466    };
467
468    messages
469        .iter()
470        .enumerate()
471        .filter_map(|(i, msg)| match msg {
472            Message::User(u) => {
473                let mut content = content_blocks_to_api(&u.content);
474                // Add cache_control to the marked message.
475                if Some(i) == cache_index
476                    && let serde_json::Value::Array(ref mut blocks) = content
477                    && let Some(last) = blocks.last_mut()
478                {
479                    last["cache_control"] = serde_json::json!({"type": "ephemeral"});
480                }
481                Some(serde_json::json!({
482                    "role": "user",
483                    "content": content,
484                }))
485            }
486            Message::Assistant(a) => Some(serde_json::json!({
487                "role": "assistant",
488                "content": content_blocks_to_api(&a.content),
489            })),
490            Message::System(_) => None,
491        })
492        .collect()
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498
499    #[test]
500    fn test_user_message_creates_text() {
501        let msg = user_message("hello");
502        if let Message::User(u) = &msg {
503            assert_eq!(u.content.len(), 1);
504            assert_eq!(u.content[0].as_text(), Some("hello"));
505            assert!(!u.is_meta);
506        } else {
507            panic!("Expected User");
508        }
509    }
510
511    #[test]
512    fn test_tool_result_message_success() {
513        let msg = tool_result_message("c1", "output", false);
514        if let Message::User(u) = &msg {
515            assert!(u.is_meta);
516            if let ContentBlock::ToolResult {
517                tool_use_id,
518                is_error,
519                ..
520            } = &u.content[0]
521            {
522                assert_eq!(tool_use_id, "c1");
523                assert!(!is_error);
524            }
525        }
526    }
527
528    #[test]
529    fn test_tool_result_message_error() {
530        let msg = tool_result_message("c2", "fail", true);
531        if let Message::User(u) = &msg
532            && let ContentBlock::ToolResult { is_error, .. } = &u.content[0]
533        {
534            assert!(is_error);
535        }
536    }
537
538    #[test]
539    fn test_as_text() {
540        assert_eq!(
541            ContentBlock::Text { text: "hi".into() }.as_text(),
542            Some("hi")
543        );
544        assert_eq!(
545            ContentBlock::ToolUse {
546                id: "1".into(),
547                name: "X".into(),
548                input: serde_json::json!({})
549            }
550            .as_text(),
551            None
552        );
553    }
554
555    #[test]
556    fn test_as_tool_use() {
557        let b = ContentBlock::ToolUse {
558            id: "a".into(),
559            name: "B".into(),
560            input: serde_json::json!(1),
561        };
562        let (id, name, _) = b.as_tool_use().unwrap();
563        assert_eq!(id, "a");
564        assert_eq!(name, "B");
565        assert!(
566            ContentBlock::Text { text: "x".into() }
567                .as_tool_use()
568                .is_none()
569        );
570    }
571
572    #[test]
573    fn test_usage_total() {
574        let u = Usage {
575            input_tokens: 10,
576            output_tokens: 20,
577            cache_creation_input_tokens: 3,
578            cache_read_input_tokens: 7,
579        };
580        assert_eq!(u.total(), 40);
581    }
582
583    #[test]
584    fn test_usage_merge() {
585        let mut u = Usage {
586            input_tokens: 100,
587            output_tokens: 50,
588            ..Default::default()
589        };
590        u.merge(&Usage {
591            input_tokens: 200,
592            output_tokens: 30,
593            cache_creation_input_tokens: 5,
594            cache_read_input_tokens: 10,
595        });
596        assert_eq!(u.input_tokens, 200);
597        assert_eq!(u.output_tokens, 80);
598        assert_eq!(u.cache_creation_input_tokens, 5);
599    }
600
601    #[test]
602    fn test_usage_default() {
603        assert_eq!(Usage::default().total(), 0);
604    }
605
606    #[test]
607    fn test_message_uuid_accessible() {
608        let _ = user_message("t").uuid();
609    }
610
611    #[test]
612    fn test_messages_to_api_params_filters_system() {
613        let messages = vec![user_message("hi")];
614        let params = messages_to_api_params(&messages);
615        assert_eq!(params.len(), 1);
616        assert_eq!(params[0]["role"], "user");
617    }
618}