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