llm_kernel/llm/
history.rs1use crate::llm::types::{ChatMessage, LLMRequest, MessageRole};
27use crate::tokens::budget::TokenBudget;
28use crate::tokens::estimate_tokens;
29
30#[derive(Debug, Clone)]
33pub struct ConversationHistory {
34 messages: Vec<ChatMessage>,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct RoleValidationError {
40 pub attempted: MessageRole,
42 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 pub fn new() -> Self {
61 Self {
62 messages: Vec::new(),
63 }
64 }
65
66 pub fn len(&self) -> usize {
68 self.messages.len()
69 }
70
71 pub fn is_empty(&self) -> bool {
73 self.messages.is_empty()
74 }
75
76 pub fn messages(&self) -> &[ChatMessage] {
78 &self.messages
79 }
80
81 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 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, });
119 }
120 }
121 }
122 self.messages.push(message);
123 Ok(())
124 }
125
126 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 pub fn truncate_to_budget(&mut self, budget: &TokenBudget, needed: u32) -> usize {
143 if budget.try_reserve(needed) {
144 return 0;
145 }
146 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 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 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 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 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 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 let removed = h.truncate_to_budget(&budget, 50);
294 assert!(removed > 0);
295 assert_eq!(h.len(), len_before - removed);
297 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 let removed = h.truncate_to_budget(&budget, 100);
309 assert_eq!(removed, 0); 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}