Skip to main content

synwire_core/messages/
utils.rs

1//! Message utility functions for trimming and merging.
2
3use crate::messages::{Message, MessageContent};
4
5/// Strategy for trimming messages.
6#[derive(Debug, Clone, PartialEq, Eq)]
7#[non_exhaustive]
8pub enum TrimStrategy {
9    /// Remove from the beginning (keep most recent).
10    First,
11    /// Remove from the end (keep oldest).
12    Last,
13}
14
15/// Estimate token count for a message using character-based approximation.
16///
17/// Uses a simple heuristic: 1 token is approximately 4 characters.
18fn estimate_tokens(msg: &Message) -> usize {
19    let text_len = msg.content().as_text().len();
20    text_len.div_ceil(4)
21}
22
23/// Trim messages to fit within a token budget.
24///
25/// Uses a simple approximation: 1 token is approximately 4 characters.
26/// When the total estimated tokens exceed `max_tokens`, messages are removed
27/// according to the chosen strategy.
28///
29/// # Examples
30///
31/// ```
32/// use synwire_core::messages::{Message, trim_messages, TrimStrategy};
33///
34/// let messages = vec![
35///     Message::system("You are helpful"),
36///     Message::human("Hello"),
37///     Message::ai("Hi there! How can I help you today?"),
38/// ];
39///
40/// // Keep only messages that fit in 10 tokens
41/// let trimmed = trim_messages(&messages, 10, &TrimStrategy::First);
42/// assert!(trimmed.len() <= messages.len());
43/// ```
44pub fn trim_messages(
45    messages: &[Message],
46    max_tokens: usize,
47    strategy: &TrimStrategy,
48) -> Vec<Message> {
49    let total: usize = messages.iter().map(estimate_tokens).sum();
50
51    if total <= max_tokens {
52        return messages.to_vec();
53    }
54
55    match strategy {
56        TrimStrategy::First => {
57            // Remove from the beginning, keep most recent
58            let mut result = Vec::new();
59            let mut budget = max_tokens;
60
61            for msg in messages.iter().rev() {
62                let tokens = estimate_tokens(msg);
63                if tokens <= budget {
64                    result.push(msg.clone());
65                    budget -= tokens;
66                } else {
67                    break;
68                }
69            }
70
71            result.reverse();
72            result
73        }
74        TrimStrategy::Last => {
75            // Remove from the end, keep oldest
76            let mut result = Vec::new();
77            let mut budget = max_tokens;
78
79            for msg in messages {
80                let tokens = estimate_tokens(msg);
81                if tokens <= budget {
82                    result.push(msg.clone());
83                    budget -= tokens;
84                } else {
85                    break;
86                }
87            }
88
89            result
90        }
91    }
92}
93
94/// Merge consecutive messages of the same type into single messages.
95///
96/// When multiple messages of the same type appear consecutively, their text
97/// content is concatenated with newlines. Non-consecutive messages of the
98/// same type are not merged.
99///
100/// # Examples
101///
102/// ```
103/// use synwire_core::messages::{Message, merge_message_runs};
104///
105/// let messages = vec![
106///     Message::human("Hello"),
107///     Message::human("How are you?"),
108///     Message::ai("I'm fine"),
109/// ];
110///
111/// let merged = merge_message_runs(&messages);
112/// assert_eq!(merged.len(), 2);
113/// assert_eq!(merged[0].content().as_text(), "Hello\nHow are you?");
114/// ```
115pub fn merge_message_runs(messages: &[Message]) -> Vec<Message> {
116    if messages.is_empty() {
117        return Vec::new();
118    }
119
120    let mut result: Vec<Message> = Vec::new();
121
122    for msg in messages {
123        let should_merge = result
124            .last()
125            .is_some_and(|last| last.message_type() == msg.message_type());
126
127        if should_merge {
128            // Merge content with the last message
129            if let Some(last) = result.last_mut() {
130                let combined = format!("{}\n{}", last.content().as_text(), msg.content().as_text());
131                let new_content = MessageContent::Text(combined);
132                replace_content(last, new_content);
133            }
134        } else {
135            result.push(msg.clone());
136        }
137    }
138
139    result
140}
141
142/// Replace the content of a message in-place.
143fn replace_content(msg: &mut Message, new_content: MessageContent) {
144    match msg {
145        Message::Human { content, .. }
146        | Message::AI { content, .. }
147        | Message::System { content, .. }
148        | Message::Tool { content, .. }
149        | Message::Chat { content, .. } => {
150            *content = new_content;
151        }
152    }
153}
154
155#[cfg(test)]
156#[allow(clippy::unwrap_used)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn test_trim_messages_first_strategy() {
162        // Create messages where total tokens exceed budget
163        let messages = vec![
164            Message::system("You are a helpful assistant"),
165            Message::human("Hello there"),
166            Message::ai("Hi"),
167        ];
168
169        // Use a small budget that can only fit the last message
170        let trimmed = trim_messages(&messages, 2, &TrimStrategy::First);
171        // Should keep only most recent messages that fit
172        assert!(!trimmed.is_empty());
173        assert!(trimmed.len() < messages.len());
174        // Last message should be the AI message
175        assert_eq!(trimmed.last().map(Message::message_type), Some("ai"));
176    }
177
178    #[test]
179    fn test_trim_messages_last_strategy() {
180        let messages = vec![
181            Message::human("Hi"),
182            Message::ai("Hello! How can I help you today with your questions?"),
183            Message::human("Tell me about Rust"),
184        ];
185
186        // Small budget keeps only first messages
187        let trimmed = trim_messages(&messages, 3, &TrimStrategy::Last);
188        assert!(!trimmed.is_empty());
189        assert_eq!(trimmed[0].message_type(), "human");
190    }
191
192    #[test]
193    fn test_trim_messages_within_budget() {
194        let messages = vec![Message::human("Hi"), Message::ai("Hello")];
195
196        // Large budget keeps everything
197        let trimmed = trim_messages(&messages, 1000, &TrimStrategy::First);
198        assert_eq!(trimmed.len(), 2);
199    }
200
201    #[test]
202    fn test_trim_messages_empty() {
203        let trimmed = trim_messages(&[], 100, &TrimStrategy::First);
204        assert!(trimmed.is_empty());
205    }
206
207    #[test]
208    fn test_merge_message_runs() {
209        let messages = vec![
210            Message::human("Hello"),
211            Message::human("How are you?"),
212            Message::ai("I'm fine"),
213            Message::ai("Thanks for asking"),
214            Message::human("Great"),
215        ];
216
217        let merged = merge_message_runs(&messages);
218        assert_eq!(merged.len(), 3);
219        assert_eq!(merged[0].content().as_text(), "Hello\nHow are you?");
220        assert_eq!(merged[1].content().as_text(), "I'm fine\nThanks for asking");
221        assert_eq!(merged[2].content().as_text(), "Great");
222    }
223
224    #[test]
225    fn test_merge_message_runs_no_consecutive() {
226        let messages = vec![
227            Message::human("Hello"),
228            Message::ai("Hi"),
229            Message::human("How are you?"),
230        ];
231
232        let merged = merge_message_runs(&messages);
233        assert_eq!(merged.len(), 3);
234    }
235
236    #[test]
237    fn test_merge_message_runs_empty() {
238        let merged = merge_message_runs(&[]);
239        assert!(merged.is_empty());
240    }
241
242    #[test]
243    fn test_merge_message_runs_single() {
244        let messages = vec![Message::human("Hello")];
245        let merged = merge_message_runs(&messages);
246        assert_eq!(merged.len(), 1);
247        assert_eq!(merged[0].content().as_text(), "Hello");
248    }
249}