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            reasoning: None,
193            verbosity: None,
194            extra_body: None,
195        }
196    }
197}
198
199impl Default for ConversationHistory {
200    fn default() -> Self {
201        Self::new()
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn push_user_then_assistant() {
211        let mut h = ConversationHistory::new();
212        assert!(h.push(ChatMessage::user("hello")).is_ok());
213        assert!(h.push(ChatMessage::assistant("hi")).is_ok());
214        assert_eq!(h.len(), 2);
215    }
216
217    #[test]
218    fn push_system_then_user() {
219        let mut h = ConversationHistory::new();
220        assert!(h.push(ChatMessage::system("you are helpful")).is_ok());
221        assert!(h.push(ChatMessage::user("hello")).is_ok());
222        assert_eq!(h.len(), 2);
223    }
224
225    #[test]
226    fn push_rejects_system_after_user() {
227        let mut h = ConversationHistory::new();
228        h.push(ChatMessage::user("hello")).unwrap();
229        let err = h.push(ChatMessage::system("nope")).unwrap_err();
230        assert_eq!(err.attempted, MessageRole::System);
231        assert_eq!(err.previous, MessageRole::User);
232    }
233
234    #[test]
235    fn push_rejects_double_user() {
236        let mut h = ConversationHistory::new();
237        h.push(ChatMessage::user("first")).unwrap();
238        let err = h.push(ChatMessage::user("second")).unwrap_err();
239        assert_eq!(err.attempted, MessageRole::User);
240        assert_eq!(err.previous, MessageRole::User);
241    }
242
243    #[test]
244    fn push_rejects_assistant_first() {
245        let mut h = ConversationHistory::new();
246        let err = h.push(ChatMessage::assistant("hi")).unwrap_err();
247        assert_eq!(err.attempted, MessageRole::Assistant);
248    }
249
250    #[test]
251    fn push_tool_after_assistant() {
252        let mut h = ConversationHistory::new();
253        h.push(ChatMessage::user("run tool")).unwrap();
254        h.push(ChatMessage::assistant("calling tool")).unwrap();
255        assert!(h.push(ChatMessage::tool("result")).is_ok());
256        assert_eq!(h.len(), 3);
257    }
258
259    #[test]
260    fn push_consecutive_tools_allowed() {
261        // Parallel tool calls produce multiple Tool results in a row.
262        let mut h = ConversationHistory::new();
263        h.push(ChatMessage::user("run tools")).unwrap();
264        h.push(ChatMessage::assistant("calling")).unwrap();
265        assert!(h.push(ChatMessage::tool("result1")).is_ok());
266        assert!(h.push(ChatMessage::tool("result2")).is_ok());
267        assert_eq!(h.len(), 4);
268    }
269
270    #[test]
271    fn into_request_sets_system_prompt() {
272        let mut h = ConversationHistory::new();
273        h.push(ChatMessage::system("original")).unwrap();
274        h.push(ChatMessage::user("hello")).unwrap();
275
276        let req = h.into_request("new system prompt");
277        assert_eq!(req.system.as_deref(), Some("new system prompt"));
278        // System message filtered out of messages
279        assert_eq!(req.messages.len(), 1);
280        assert_eq!(req.messages[0].role, MessageRole::User);
281    }
282
283    #[test]
284    fn truncate_to_budget_removes_oldest() {
285        let budget = TokenBudget::new(100);
286        // Pre-fill the budget so there's no room
287        assert!(budget.try_reserve(90));
288
289        let mut h = ConversationHistory::new();
290        h.push(ChatMessage::system("system")).unwrap();
291        h.push(ChatMessage::user("x".repeat(200))).unwrap();
292        h.push(ChatMessage::assistant("y".repeat(200))).unwrap();
293        let len_before = h.len();
294
295        // Need 50 tokens but only 10 remaining → must truncate
296        let removed = h.truncate_to_budget(&budget, 50);
297        assert!(removed > 0);
298        // Messages were actually removed from the history
299        assert_eq!(h.len(), len_before - removed);
300        // The leading System message is preserved
301        assert_eq!(h.messages()[0].role, MessageRole::System);
302    }
303
304    #[test]
305    fn truncate_preserves_system_message() {
306        let budget = TokenBudget::new(5);
307        let mut h = ConversationHistory::new();
308        h.push(ChatMessage::system("system instruction")).unwrap();
309
310        // Even with very small budget, system message stays
311        let removed = h.truncate_to_budget(&budget, 100);
312        assert_eq!(removed, 0); // nothing to remove beyond system
313        assert_eq!(h.messages()[0].role, MessageRole::System);
314    }
315
316    #[test]
317    fn token_count_estimates() {
318        let mut h = ConversationHistory::new();
319        assert_eq!(h.token_count(), 0);
320        h.push(ChatMessage::user("hello world")).unwrap();
321        assert!(h.token_count() > 0);
322    }
323
324    #[test]
325    fn default_is_empty() {
326        let h = ConversationHistory::default();
327        assert!(h.is_empty());
328    }
329}