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