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