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 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 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 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 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 let removed = h.truncate_to_budget(&budget, 50);
297 assert!(removed > 0);
298 assert_eq!(h.len(), len_before - removed);
300 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 let removed = h.truncate_to_budget(&budget, 100);
312 assert_eq!(removed, 0); 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}