Skip to main content

llm_kernel/llm/
history.rs

1//! Conversation history management with token-budget-aware truncation.
2//!
3//! [`ConversationHistory`] holds an ordered list of [`ChatMessage`] entries
4//! and enforces role alternation rules. When the conversation grows too long,
5//! [`truncate_to_budget`](ConversationHistory::truncate_to_budget) removes
6//! the oldest non-system messages until a [`TokenBudget`](crate::tokens::budget::TokenBudget)
7//! has enough remaining capacity.
8//!
9//! # Example
10//!
11//! ```
12//! use llm_kernel::llm::{ConversationHistory, ChatMessage};
13//! use llm_kernel::tokens::budget::TokenBudget;
14//!
15//! let mut history = ConversationHistory::new();
16//! history.push(ChatMessage::user("What is Rust?")).unwrap();
17//! history.push(ChatMessage::assistant("A systems programming language.")).unwrap();
18//!
19//! let budget = TokenBudget::new(1000);
20//! history.truncate_to_budget(&budget, 50);
21//!
22//! let request = history.clone().into_request("You are a helpful assistant.");
23//! assert_eq!(request.system.as_deref(), Some("You are a helpful assistant."));
24//! ```
25
26use crate::llm::types::{ChatMessage, LLMRequest, MessageRole};
27use crate::tokens::budget::TokenBudget;
28use crate::tokens::estimate_tokens;
29
30/// Manages an ordered conversation history with role validation and
31/// token-budget-aware truncation.
32#[derive(Debug, Clone)]
33pub struct ConversationHistory {
34    messages: Vec<ChatMessage>,
35}
36
37/// Error returned when a message has an invalid role for the current position.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct RoleValidationError {
40    /// The role that was rejected.
41    pub attempted: MessageRole,
42    /// The role of the preceding message.
43    pub previous: MessageRole,
44}
45
46impl std::fmt::Display for RoleValidationError {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        write!(
49            f,
50            "invalid role transition: {:?} after {:?}",
51            self.attempted, self.previous
52        )
53    }
54}
55
56impl std::error::Error for RoleValidationError {}
57
58impl ConversationHistory {
59    /// Create a new empty conversation history.
60    pub fn new() -> Self {
61        Self {
62            messages: Vec::new(),
63        }
64    }
65
66    /// Number of messages in the history.
67    pub fn len(&self) -> usize {
68        self.messages.len()
69    }
70
71    /// Whether the history is empty.
72    pub fn is_empty(&self) -> bool {
73        self.messages.is_empty()
74    }
75
76    /// Access the messages.
77    pub fn messages(&self) -> &[ChatMessage] {
78        &self.messages
79    }
80
81    /// Append a message with role alternation validation.
82    ///
83    /// Rules:
84    /// - First message can be `System`, `User`, or `Tool`.
85    /// - `System` is only allowed as the first message.
86    /// - `User` must follow `Assistant` or be first.
87    /// - `Assistant` must follow `User` or `Tool`.
88    /// - `Tool` must follow `Assistant` or another `Tool` (parallel tool results).
89    pub fn push(&mut self, message: ChatMessage) -> Result<(), RoleValidationError> {
90        if let Some(last) = self.messages.last() {
91            let valid = match message.role {
92                MessageRole::System => false,
93                MessageRole::User => {
94                    matches!(last.role, MessageRole::Assistant)
95                        || matches!(last.role, MessageRole::System)
96                }
97                MessageRole::Assistant => {
98                    matches!(last.role, MessageRole::User | MessageRole::Tool)
99                }
100                MessageRole::Tool => {
101                    matches!(last.role, MessageRole::Assistant | MessageRole::Tool)
102                }
103            };
104            if !valid {
105                return Err(RoleValidationError {
106                    attempted: message.role,
107                    previous: last.role,
108                });
109            }
110        } else {
111            // First message: System, User, or Tool are valid
112            match message.role {
113                MessageRole::System | MessageRole::User | MessageRole::Tool => {}
114                MessageRole::Assistant => {
115                    return Err(RoleValidationError {
116                        attempted: message.role,
117                        previous: MessageRole::System, // sentinel: "no previous"
118                    });
119                }
120            }
121        }
122        self.messages.push(message);
123        Ok(())
124    }
125
126    /// Estimate the total token count of all messages.
127    pub fn token_count(&self) -> u32 {
128        self.messages
129            .iter()
130            .map(|m| estimate_tokens(&m.text_content()) as u32)
131            .sum()
132    }
133
134    /// Remove the oldest non-system messages until the token budget has
135    /// enough remaining capacity for `needed` additional tokens.
136    ///
137    /// If the history starts with a `System` message, it is always preserved.
138    /// Messages are removed from the front (oldest first) until
139    /// `budget.try_reserve(needed)` succeeds or only the system message remains.
140    ///
141    /// Returns the number of messages removed.
142    pub fn truncate_to_budget(&mut self, budget: &TokenBudget, needed: u32) -> usize {
143        if budget.try_reserve(needed) {
144            return 0;
145        }
146        // Index 0 is preserved when it is a System message; truncation starts
147        // after it (or at 0 otherwise).
148        let start = if self
149            .messages
150            .first()
151            .is_some_and(|m| m.role == MessageRole::System)
152        {
153            1
154        } else {
155            0
156        };
157        let mut removed = 0;
158        // Remove oldest non-system messages in place until the budget fits.
159        while start < self.messages.len() {
160            if budget.try_reserve(needed) {
161                break;
162            }
163            let tokens = estimate_tokens(&self.messages[start].text_content()) as u32;
164            budget.release(tokens);
165            self.messages.remove(start);
166            removed += 1;
167        }
168        removed
169    }
170
171    /// Convert the history into an [`LLMRequest`] with the given system prompt.
172    ///
173    /// Always sets the request's `system` field to `system_prompt`. Any `System`
174    /// messages in the history are filtered out of the message list, since the
175    /// system prompt is conveyed via the request's `system` field. The request
176    /// is created with a default `temperature` of `0.7`.
177    pub fn into_request(self, system_prompt: impl Into<String>) -> LLMRequest {
178        let system = Some(system_prompt.into());
179        let messages = self
180            .messages
181            .into_iter()
182            .filter(|m| m.role != MessageRole::System)
183            .collect();
184        LLMRequest {
185            system,
186            messages,
187            temperature: 0.7,
188            max_tokens: None,
189            model: None,
190            response_format: None,
191            tools: None,
192        }
193    }
194}
195
196impl Default for ConversationHistory {
197    fn default() -> Self {
198        Self::new()
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn push_user_then_assistant() {
208        let mut h = ConversationHistory::new();
209        assert!(h.push(ChatMessage::user("hello")).is_ok());
210        assert!(h.push(ChatMessage::assistant("hi")).is_ok());
211        assert_eq!(h.len(), 2);
212    }
213
214    #[test]
215    fn push_system_then_user() {
216        let mut h = ConversationHistory::new();
217        assert!(h.push(ChatMessage::system("you are helpful")).is_ok());
218        assert!(h.push(ChatMessage::user("hello")).is_ok());
219        assert_eq!(h.len(), 2);
220    }
221
222    #[test]
223    fn push_rejects_system_after_user() {
224        let mut h = ConversationHistory::new();
225        h.push(ChatMessage::user("hello")).unwrap();
226        let err = h.push(ChatMessage::system("nope")).unwrap_err();
227        assert_eq!(err.attempted, MessageRole::System);
228        assert_eq!(err.previous, MessageRole::User);
229    }
230
231    #[test]
232    fn push_rejects_double_user() {
233        let mut h = ConversationHistory::new();
234        h.push(ChatMessage::user("first")).unwrap();
235        let err = h.push(ChatMessage::user("second")).unwrap_err();
236        assert_eq!(err.attempted, MessageRole::User);
237        assert_eq!(err.previous, MessageRole::User);
238    }
239
240    #[test]
241    fn push_rejects_assistant_first() {
242        let mut h = ConversationHistory::new();
243        let err = h.push(ChatMessage::assistant("hi")).unwrap_err();
244        assert_eq!(err.attempted, MessageRole::Assistant);
245    }
246
247    #[test]
248    fn push_tool_after_assistant() {
249        let mut h = ConversationHistory::new();
250        h.push(ChatMessage::user("run tool")).unwrap();
251        h.push(ChatMessage::assistant("calling tool")).unwrap();
252        assert!(h.push(ChatMessage::tool("result")).is_ok());
253        assert_eq!(h.len(), 3);
254    }
255
256    #[test]
257    fn push_consecutive_tools_allowed() {
258        // Parallel tool calls produce multiple Tool results in a row.
259        let mut h = ConversationHistory::new();
260        h.push(ChatMessage::user("run tools")).unwrap();
261        h.push(ChatMessage::assistant("calling")).unwrap();
262        assert!(h.push(ChatMessage::tool("result1")).is_ok());
263        assert!(h.push(ChatMessage::tool("result2")).is_ok());
264        assert_eq!(h.len(), 4);
265    }
266
267    #[test]
268    fn into_request_sets_system_prompt() {
269        let mut h = ConversationHistory::new();
270        h.push(ChatMessage::system("original")).unwrap();
271        h.push(ChatMessage::user("hello")).unwrap();
272
273        let req = h.into_request("new system prompt");
274        assert_eq!(req.system.as_deref(), Some("new system prompt"));
275        // System message filtered out of messages
276        assert_eq!(req.messages.len(), 1);
277        assert_eq!(req.messages[0].role, MessageRole::User);
278    }
279
280    #[test]
281    fn truncate_to_budget_removes_oldest() {
282        let budget = TokenBudget::new(100);
283        // Pre-fill the budget so there's no room
284        assert!(budget.try_reserve(90));
285
286        let mut h = ConversationHistory::new();
287        h.push(ChatMessage::system("system")).unwrap();
288        h.push(ChatMessage::user("x".repeat(200))).unwrap();
289        h.push(ChatMessage::assistant("y".repeat(200))).unwrap();
290        let len_before = h.len();
291
292        // Need 50 tokens but only 10 remaining → must truncate
293        let removed = h.truncate_to_budget(&budget, 50);
294        assert!(removed > 0);
295        // Messages were actually removed from the history
296        assert_eq!(h.len(), len_before - removed);
297        // The leading System message is preserved
298        assert_eq!(h.messages()[0].role, MessageRole::System);
299    }
300
301    #[test]
302    fn truncate_preserves_system_message() {
303        let budget = TokenBudget::new(5);
304        let mut h = ConversationHistory::new();
305        h.push(ChatMessage::system("system instruction")).unwrap();
306
307        // Even with very small budget, system message stays
308        let removed = h.truncate_to_budget(&budget, 100);
309        assert_eq!(removed, 0); // nothing to remove beyond system
310        assert_eq!(h.messages()[0].role, MessageRole::System);
311    }
312
313    #[test]
314    fn token_count_estimates() {
315        let mut h = ConversationHistory::new();
316        assert_eq!(h.token_count(), 0);
317        h.push(ChatMessage::user("hello world")).unwrap();
318        assert!(h.token_count() > 0);
319    }
320
321    #[test]
322    fn default_is_empty() {
323        let h = ConversationHistory::default();
324        assert!(h.is_empty());
325    }
326}