Skip to main content

axon/
conversation.rs

1//! Conversation history — multi-turn message accumulation between steps.
2//!
3//! Maintains an ordered sequence of user/assistant messages within an
4//! execution unit, enabling contextual dialogue where each LLM step
5//! sees the full conversation history from prior steps.
6//!
7//! Message roles:
8//!   "user"      — user prompt sent to the LLM
9//!   "assistant" — LLM response text
10//!
11//! The system prompt is NOT part of the history — it is rebuilt per step
12//! (unit-level + step-level) and passed separately to the backend.
13//!
14//! Context window management:
15//!   The `ContextWindow` struct enforces a character budget on conversation
16//!   history. When history exceeds the budget, oldest turn pairs are dropped
17//!   (sliding window). Characters are used as a proxy for tokens (~4 chars/token).
18//!
19//!   Default budget: 100,000 characters (~25k tokens).
20//!   Budget 0 means unlimited (no truncation).
21
22use serde::{Deserialize, Serialize};
23
24/// A single message in a conversation.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct Message {
27    pub role: String,
28    pub content: String,
29}
30
31impl Message {
32    /// Create a user message.
33    pub fn user(content: &str) -> Self {
34        Message {
35            role: "user".to_string(),
36            content: content.to_string(),
37        }
38    }
39
40    /// Create an assistant message.
41    pub fn assistant(content: &str) -> Self {
42        Message {
43            role: "assistant".to_string(),
44            content: content.to_string(),
45        }
46    }
47}
48
49/// Conversation history — accumulates messages within an execution unit.
50#[derive(Debug, Clone)]
51pub struct ConversationHistory {
52    messages: Vec<Message>,
53}
54
55impl ConversationHistory {
56    /// Create an empty conversation history.
57    pub fn new() -> Self {
58        ConversationHistory {
59            messages: Vec::new(),
60        }
61    }
62
63    /// Add a user message to the history.
64    pub fn add_user(&mut self, content: &str) {
65        self.messages.push(Message::user(content));
66    }
67
68    /// Add an assistant message to the history.
69    pub fn add_assistant(&mut self, content: &str) {
70        self.messages.push(Message::assistant(content));
71    }
72
73    /// Get all messages as a slice.
74    pub fn messages(&self) -> &[Message] {
75        &self.messages
76    }
77
78    /// Number of messages in the history.
79    pub fn len(&self) -> usize {
80        self.messages.len()
81    }
82
83    /// Whether the history is empty.
84    pub fn is_empty(&self) -> bool {
85        self.messages.is_empty()
86    }
87
88    /// Number of turn pairs (user+assistant) completed.
89    pub fn turn_count(&self) -> usize {
90        self.messages.len() / 2
91    }
92
93    /// Total character count across all messages (for context budget estimation).
94    pub fn total_chars(&self) -> usize {
95        self.messages.iter().map(|m| m.content.len()).sum()
96    }
97
98    /// Clear all messages.
99    pub fn clear(&mut self) {
100        self.messages.clear();
101    }
102
103    /// Enforce a character budget by dropping the oldest turn pairs.
104    ///
105    /// Removes messages from the front in pairs (user + assistant) until
106    /// `total_chars()` is at or below `max_chars`. If a single turn exceeds
107    /// the budget, it is kept (we never drop all context — at minimum the
108    /// most recent turn is preserved).
109    ///
110    /// Returns the number of messages dropped.
111    pub fn truncate_to_budget(&mut self, max_chars: usize) -> usize {
112        if max_chars == 0 || self.total_chars() <= max_chars {
113            return 0;
114        }
115
116        let mut dropped = 0;
117
118        // Drop oldest turn pairs (2 messages at a time) while over budget
119        // Always keep at least 2 messages (the most recent turn).
120        while self.messages.len() > 2 && self.total_chars() > max_chars {
121            // Remove first two messages (user + assistant pair)
122            self.messages.remove(0);
123            self.messages.remove(0);
124            dropped += 2;
125        }
126
127        dropped
128    }
129
130    /// Count of messages that would be dropped to meet a budget,
131    /// without actually modifying the history.
132    pub fn overflow_count(&self, max_chars: usize) -> usize {
133        if max_chars == 0 || self.total_chars() <= max_chars {
134            return 0;
135        }
136
137        let mut chars = self.total_chars();
138        let mut dropped = 0;
139        let mut idx = 0;
140
141        while (self.messages.len() - dropped) > 2 && chars > max_chars {
142            chars -= self.messages[idx].content.len();
143            chars -= self.messages[idx + 1].content.len();
144            dropped += 2;
145            idx += 2;
146        }
147
148        dropped
149    }
150}
151
152/// Context window configuration — controls conversation budget.
153///
154/// Characters are used as a proxy for tokens. A rough heuristic:
155///   ~4 characters ≈ 1 token (English text average).
156///
157/// The budget covers only conversation history messages, not the
158/// system prompt (which is always sent separately).
159#[derive(Debug, Clone)]
160pub struct ContextWindow {
161    /// Maximum characters allowed in conversation history.
162    /// 0 means unlimited.
163    pub max_chars: usize,
164    /// Total messages dropped across all truncations in this unit.
165    pub total_dropped: usize,
166    /// Number of truncation events.
167    pub truncation_count: usize,
168}
169
170/// Default budget: 100,000 chars (~25k tokens).
171const DEFAULT_CONTEXT_BUDGET: usize = 100_000;
172
173impl ContextWindow {
174    /// Create with default budget (100k chars).
175    pub fn new() -> Self {
176        ContextWindow {
177            max_chars: DEFAULT_CONTEXT_BUDGET,
178            total_dropped: 0,
179            truncation_count: 0,
180        }
181    }
182
183    /// Create with a custom character budget. 0 = unlimited.
184    pub fn with_budget(max_chars: usize) -> Self {
185        ContextWindow {
186            max_chars,
187            total_dropped: 0,
188            truncation_count: 0,
189        }
190    }
191
192    /// Create with unlimited budget (no truncation).
193    pub fn unlimited() -> Self {
194        ContextWindow {
195            max_chars: 0,
196            total_dropped: 0,
197            truncation_count: 0,
198        }
199    }
200
201    /// Enforce the budget on a conversation history.
202    /// Returns the number of messages dropped (0 if within budget).
203    pub fn enforce(&mut self, history: &mut ConversationHistory) -> usize {
204        let dropped = history.truncate_to_budget(self.max_chars);
205        if dropped > 0 {
206            self.total_dropped += dropped;
207            self.truncation_count += 1;
208        }
209        dropped
210    }
211
212    /// Whether any truncation has occurred.
213    pub fn was_truncated(&self) -> bool {
214        self.total_dropped > 0
215    }
216
217    /// Estimated token count from character count (~4 chars/token).
218    pub fn estimate_tokens(chars: usize) -> usize {
219        (chars + 3) / 4 // ceiling division
220    }
221}
222
223// ── Tests ──────────────────────────────────────────────────────────────────
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn new_history_is_empty() {
231        let h = ConversationHistory::new();
232        assert!(h.is_empty());
233        assert_eq!(h.len(), 0);
234        assert_eq!(h.turn_count(), 0);
235        assert_eq!(h.total_chars(), 0);
236    }
237
238    #[test]
239    fn add_user_and_assistant() {
240        let mut h = ConversationHistory::new();
241        h.add_user("Hello");
242        h.add_assistant("Hi there");
243        assert_eq!(h.len(), 2);
244        assert_eq!(h.turn_count(), 1);
245        assert!(!h.is_empty());
246    }
247
248    #[test]
249    fn messages_preserve_order() {
250        let mut h = ConversationHistory::new();
251        h.add_user("First");
252        h.add_assistant("Second");
253        h.add_user("Third");
254        h.add_assistant("Fourth");
255
256        let msgs = h.messages();
257        assert_eq!(msgs.len(), 4);
258        assert_eq!(msgs[0].role, "user");
259        assert_eq!(msgs[0].content, "First");
260        assert_eq!(msgs[1].role, "assistant");
261        assert_eq!(msgs[1].content, "Second");
262        assert_eq!(msgs[2].role, "user");
263        assert_eq!(msgs[2].content, "Third");
264        assert_eq!(msgs[3].role, "assistant");
265        assert_eq!(msgs[3].content, "Fourth");
266    }
267
268    #[test]
269    fn total_chars_sums_all() {
270        let mut h = ConversationHistory::new();
271        h.add_user("abc");     // 3
272        h.add_assistant("de"); // 2
273        h.add_user("f");       // 1
274        assert_eq!(h.total_chars(), 6);
275    }
276
277    #[test]
278    fn clear_resets() {
279        let mut h = ConversationHistory::new();
280        h.add_user("Hello");
281        h.add_assistant("Hi");
282        h.clear();
283        assert!(h.is_empty());
284        assert_eq!(h.len(), 0);
285        assert_eq!(h.turn_count(), 0);
286    }
287
288    #[test]
289    fn message_constructors() {
290        let u = Message::user("question");
291        assert_eq!(u.role, "user");
292        assert_eq!(u.content, "question");
293
294        let a = Message::assistant("answer");
295        assert_eq!(a.role, "assistant");
296        assert_eq!(a.content, "answer");
297    }
298
299    #[test]
300    fn turn_count_with_odd_messages() {
301        let mut h = ConversationHistory::new();
302        h.add_user("Hello");
303        // No assistant response yet
304        assert_eq!(h.turn_count(), 0); // integer division: 1/2 = 0
305        h.add_assistant("Hi");
306        assert_eq!(h.turn_count(), 1);
307        h.add_user("Next");
308        assert_eq!(h.turn_count(), 1); // 3/2 = 1
309    }
310
311    #[test]
312    fn multi_turn_accumulation() {
313        let mut h = ConversationHistory::new();
314        for i in 0..5 {
315            h.add_user(&format!("Q{i}"));
316            h.add_assistant(&format!("A{i}"));
317        }
318        assert_eq!(h.len(), 10);
319        assert_eq!(h.turn_count(), 5);
320        // Verify last pair
321        let msgs = h.messages();
322        assert_eq!(msgs[8].content, "Q4");
323        assert_eq!(msgs[9].content, "A4");
324    }
325
326    // ── Context window tests ──────────────────────────────────────────
327
328    #[test]
329    fn truncate_within_budget_is_noop() {
330        let mut h = ConversationHistory::new();
331        h.add_user("short");
332        h.add_assistant("also short");
333        let dropped = h.truncate_to_budget(1000);
334        assert_eq!(dropped, 0);
335        assert_eq!(h.len(), 2);
336    }
337
338    #[test]
339    fn truncate_drops_oldest_turns() {
340        let mut h = ConversationHistory::new();
341        // 5 turns, each pair = "QN" (2) + "AN" (2) = 4 chars
342        for i in 0..5 {
343            h.add_user(&format!("Q{i}"));
344            h.add_assistant(&format!("A{i}"));
345        }
346        assert_eq!(h.total_chars(), 20); // 10 messages × 2 chars
347
348        // Budget = 8 chars → should keep 2 turns (8 chars), drop 3 turns (6 msgs)
349        let dropped = h.truncate_to_budget(8);
350        assert_eq!(dropped, 6);
351        assert_eq!(h.len(), 4);
352        assert_eq!(h.turn_count(), 2);
353
354        // Oldest surviving should be Q3
355        let msgs = h.messages();
356        assert_eq!(msgs[0].content, "Q3");
357        assert_eq!(msgs[1].content, "A3");
358        assert_eq!(msgs[2].content, "Q4");
359        assert_eq!(msgs[3].content, "A4");
360    }
361
362    #[test]
363    fn truncate_preserves_minimum_turn() {
364        let mut h = ConversationHistory::new();
365        h.add_user(&"x".repeat(500));
366        h.add_assistant(&"y".repeat(500));
367        // Budget = 10 → way under, but we keep at least the most recent turn
368        let dropped = h.truncate_to_budget(10);
369        assert_eq!(dropped, 0);
370        assert_eq!(h.len(), 2); // preserved
371    }
372
373    #[test]
374    fn truncate_unlimited_budget_is_noop() {
375        let mut h = ConversationHistory::new();
376        for i in 0..100 {
377            h.add_user(&format!("Question {i}"));
378            h.add_assistant(&format!("Answer {i}"));
379        }
380        let dropped = h.truncate_to_budget(0); // 0 = unlimited
381        assert_eq!(dropped, 0);
382        assert_eq!(h.len(), 200);
383    }
384
385    #[test]
386    fn overflow_count_without_mutation() {
387        let mut h = ConversationHistory::new();
388        for i in 0..5 {
389            h.add_user(&format!("Q{i}"));
390            h.add_assistant(&format!("A{i}"));
391        }
392        let count = h.overflow_count(8);
393        assert_eq!(count, 6); // Same as truncate would drop
394        assert_eq!(h.len(), 10); // Not modified
395    }
396
397    #[test]
398    fn context_window_default_budget() {
399        let cw = ContextWindow::new();
400        assert_eq!(cw.max_chars, 100_000);
401        assert_eq!(cw.total_dropped, 0);
402        assert_eq!(cw.truncation_count, 0);
403        assert!(!cw.was_truncated());
404    }
405
406    #[test]
407    fn context_window_custom_budget() {
408        let cw = ContextWindow::with_budget(50_000);
409        assert_eq!(cw.max_chars, 50_000);
410    }
411
412    #[test]
413    fn context_window_unlimited() {
414        let cw = ContextWindow::unlimited();
415        assert_eq!(cw.max_chars, 0);
416    }
417
418    #[test]
419    fn context_window_enforce_tracks_stats() {
420        let mut cw = ContextWindow::with_budget(8);
421        let mut h = ConversationHistory::new();
422        for i in 0..5 {
423            h.add_user(&format!("Q{i}"));
424            h.add_assistant(&format!("A{i}"));
425        }
426
427        let dropped = cw.enforce(&mut h);
428        assert_eq!(dropped, 6);
429        assert!(cw.was_truncated());
430        assert_eq!(cw.total_dropped, 6);
431        assert_eq!(cw.truncation_count, 1);
432
433        // Second enforce — still within budget
434        let dropped2 = cw.enforce(&mut h);
435        assert_eq!(dropped2, 0);
436        assert_eq!(cw.truncation_count, 1); // No new truncation
437    }
438
439    #[test]
440    fn context_window_enforce_multiple_truncations() {
441        let mut cw = ContextWindow::with_budget(20);
442        let mut h = ConversationHistory::new();
443
444        // First batch: add 3 turns
445        for i in 0..3 {
446            h.add_user(&format!("Q{i}"));
447            h.add_assistant(&format!("A{i}"));
448        }
449        cw.enforce(&mut h); // 12 chars < 20, no truncation
450
451        // Second batch: add 5 more turns
452        for i in 3..8 {
453            h.add_user(&format!("Q{i}"));
454            h.add_assistant(&format!("A{i}"));
455        }
456        let dropped = cw.enforce(&mut h); // 32 chars > 20
457        assert!(dropped > 0);
458        assert_eq!(cw.truncation_count, 1);
459        assert!(h.total_chars() <= 20);
460    }
461
462    #[test]
463    fn estimate_tokens() {
464        assert_eq!(ContextWindow::estimate_tokens(0), 0);
465        assert_eq!(ContextWindow::estimate_tokens(4), 1);
466        assert_eq!(ContextWindow::estimate_tokens(5), 2);
467        assert_eq!(ContextWindow::estimate_tokens(100), 25);
468        assert_eq!(ContextWindow::estimate_tokens(100_000), 25_000);
469    }
470}