Skip to main content

swink_agent/
context_version.rs

1//! Context versioning and multi-layer memory.
2//!
3//! Provides snapshot-based context versioning with optional pre-computed
4//! summarization. When context is compacted, dropped messages are captured
5//! as a [`ContextVersion`] and stored via a pluggable [`ContextVersionStore`].
6//! A [`ContextSummarizer`] can produce summaries that accompany each version,
7//! enabling RAG and hierarchical context patterns.
8
9use std::sync::{Arc, Mutex};
10
11use crate::context::CompactionReport;
12use crate::context_transformer::ContextTransformer;
13use crate::types::{AgentMessage, LlmMessage};
14
15// ─── ContextVersion ──────────────────────────────────────────────────────────
16
17/// A snapshot of messages captured at a point in time.
18///
19/// Created during compaction when messages are dropped from the active context.
20/// Each version records the version number, turn number, timestamp, the dropped
21/// LLM messages, and an optional summary.
22///
23/// Only `LlmMessage` variants are stored; `CustomMessage` values are filtered
24/// out since they are not cloneable.
25#[derive(Debug, Clone)]
26pub struct ContextVersion {
27    /// Monotonically increasing version number (starts at 1).
28    pub version: u64,
29    /// Turn number when this version was created.
30    pub turn: u64,
31    /// Unix timestamp (seconds) when this version was created.
32    pub timestamp: u64,
33    /// The LLM messages that were dropped during compaction.
34    pub messages: Vec<LlmMessage>,
35    /// Optional pre-computed summary of the dropped messages.
36    pub summary: Option<String>,
37}
38
39/// Metadata for a stored context version (returned by `list_versions`).
40#[derive(Debug, Clone)]
41pub struct ContextVersionMeta {
42    /// Version number.
43    pub version: u64,
44    /// Turn number when created.
45    pub turn: u64,
46    /// Unix timestamp when created.
47    pub timestamp: u64,
48    /// Number of messages in this version.
49    pub message_count: usize,
50    /// Whether a summary is available.
51    pub has_summary: bool,
52}
53
54// ─── ContextVersionStore ─────────────────────────────────────────────────────
55
56/// Pluggable storage for context version snapshots.
57///
58/// Implementations persist dropped messages from compaction for later retrieval,
59/// enabling RAG-style recall of earlier conversation context.
60pub trait ContextVersionStore: Send + Sync {
61    /// Save a context version. Called automatically during compaction.
62    fn save_version(&self, version: &ContextVersion);
63
64    /// Load a specific version by number.
65    fn load_version(&self, version: u64) -> Option<ContextVersion>;
66
67    /// List metadata for all stored versions, ordered by version number.
68    fn list_versions(&self) -> Vec<ContextVersionMeta>;
69
70    /// Load the most recent version, if any.
71    fn latest_version(&self) -> Option<ContextVersion> {
72        let versions = self.list_versions();
73        versions
74            .last()
75            .and_then(|meta| self.load_version(meta.version))
76    }
77}
78
79// ─── ContextSummarizer ───────────────────────────────────────────────────────
80
81/// Pre-computed summarization of dropped context messages.
82///
83/// Called synchronously during compaction to produce a summary of the messages
84/// being evicted. The summary is stored alongside the version and can be
85/// injected back into context (e.g., via `SummarizingCompactor` in the memory
86/// crate).
87///
88/// For async summarization (e.g., LLM calls), pre-compute the summary
89/// externally and attach it via the version store.
90pub trait ContextSummarizer: Send + Sync {
91    /// Produce a summary of the given messages.
92    ///
93    /// Called with the messages that are about to be dropped during compaction.
94    /// Returns `None` if summarization is not possible or not desired.
95    fn summarize(&self, messages: &[LlmMessage]) -> Option<String>;
96}
97
98// ─── InMemoryVersionStore ────────────────────────────────────────────────────
99
100/// In-memory implementation of [`ContextVersionStore`].
101///
102/// Suitable for single-session usage and testing. Versions are stored in a
103/// `Vec` behind a `Mutex`.
104pub struct InMemoryVersionStore {
105    versions: Mutex<Vec<ContextVersion>>,
106}
107
108impl InMemoryVersionStore {
109    #[must_use]
110    pub const fn new() -> Self {
111        Self {
112            versions: Mutex::new(Vec::new()),
113        }
114    }
115
116    pub fn len(&self) -> usize {
117        self.versions
118            .lock()
119            .unwrap_or_else(std::sync::PoisonError::into_inner)
120            .len()
121    }
122
123    pub fn is_empty(&self) -> bool {
124        self.len() == 0
125    }
126}
127
128impl Default for InMemoryVersionStore {
129    fn default() -> Self {
130        Self::new()
131    }
132}
133
134impl ContextVersionStore for InMemoryVersionStore {
135    fn save_version(&self, version: &ContextVersion) {
136        let mut guard = self
137            .versions
138            .lock()
139            .unwrap_or_else(std::sync::PoisonError::into_inner);
140        guard.push(version.clone());
141    }
142
143    fn load_version(&self, version: u64) -> Option<ContextVersion> {
144        let guard = self
145            .versions
146            .lock()
147            .unwrap_or_else(std::sync::PoisonError::into_inner);
148        guard.iter().find(|v| v.version == version).cloned()
149    }
150
151    fn list_versions(&self) -> Vec<ContextVersionMeta> {
152        let guard = self
153            .versions
154            .lock()
155            .unwrap_or_else(std::sync::PoisonError::into_inner);
156        guard
157            .iter()
158            .map(|v| ContextVersionMeta {
159                version: v.version,
160                turn: v.turn,
161                timestamp: v.timestamp,
162                message_count: v.messages.len(),
163                has_summary: v.summary.is_some(),
164            })
165            .collect()
166    }
167}
168
169// ─── VersioningTransformer ───────────────────────────────────────────────────
170
171/// A context transformer that captures dropped messages as versioned snapshots.
172///
173/// Wraps an inner [`ContextTransformer`] (typically a sliding window) and
174/// stores evicted messages via a [`ContextVersionStore`]. An optional
175/// [`ContextSummarizer`] produces summaries for each version.
176///
177/// # Example
178///
179/// ```rust,ignore
180/// use swink_agent::{
181///     SlidingWindowTransformer, VersioningTransformer,
182///     InMemoryVersionStore,
183/// };
184/// use std::sync::Arc;
185///
186/// let store = Arc::new(InMemoryVersionStore::new());
187/// let inner = SlidingWindowTransformer::new(100_000, 50_000, 2);
188/// let transformer = VersioningTransformer::new(inner, store);
189///
190/// let agent = AgentOptions::new(/* ... */)
191///     .with_transform_context(transformer);
192/// ```
193pub struct VersioningTransformer {
194    inner: Box<dyn ContextTransformer>,
195    store: Arc<dyn ContextVersionStore>,
196    summarizer: Option<Arc<dyn ContextSummarizer>>,
197    state: Mutex<VersioningState>,
198}
199
200struct VersioningState {
201    next_version: u64,
202    turn_counter: u64,
203}
204
205impl VersioningTransformer {
206    /// Create a new versioning transformer wrapping an inner transformer.
207    pub fn new(
208        inner: impl ContextTransformer + 'static,
209        store: Arc<dyn ContextVersionStore>,
210    ) -> Self {
211        Self {
212            inner: Box::new(inner),
213            store,
214            summarizer: None,
215            state: Mutex::new(VersioningState {
216                next_version: 1,
217                turn_counter: 0,
218            }),
219        }
220    }
221
222    /// Attach a summarizer that produces summaries for each version.
223    #[must_use]
224    pub fn with_summarizer(mut self, summarizer: Arc<dyn ContextSummarizer>) -> Self {
225        self.summarizer = Some(summarizer);
226        self
227    }
228
229    /// Access the underlying version store.
230    pub fn store(&self) -> &Arc<dyn ContextVersionStore> {
231        &self.store
232    }
233}
234
235impl ContextTransformer for VersioningTransformer {
236    fn transform(
237        &self,
238        messages: &mut Vec<AgentMessage>,
239        overflow: bool,
240    ) -> Option<CompactionReport> {
241        // Run the inner transformer. The report carries the dropped LLM messages
242        // directly — no snapshot/diff reconstruction needed.
243        let report = self.inner.transform(messages, overflow)?;
244
245        if report.dropped_messages.is_empty() {
246            return Some(report);
247        }
248
249        // Build the version from the report's explicit dropped-message list.
250        let mut state = self
251            .state
252            .lock()
253            .unwrap_or_else(std::sync::PoisonError::into_inner);
254        state.turn_counter += 1;
255
256        let summary = self
257            .summarizer
258            .as_ref()
259            .and_then(|s| s.summarize(&report.dropped_messages));
260
261        let version = ContextVersion {
262            version: state.next_version,
263            turn: state.turn_counter,
264            timestamp: crate::util::now_timestamp(),
265            messages: report.dropped_messages.clone(),
266            summary,
267        };
268
269        state.next_version += 1;
270        drop(state);
271
272        self.store.save_version(&version);
273
274        Some(report)
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crate::context_transformer::SlidingWindowTransformer;
282    use crate::types::{ContentBlock, UserMessage};
283
284    fn text_message(text: &str) -> AgentMessage {
285        AgentMessage::Llm(LlmMessage::User(UserMessage {
286            content: vec![ContentBlock::Text {
287                text: text.to_owned(),
288            }],
289            timestamp: 0,
290            cache_hint: None,
291        }))
292    }
293
294    #[test]
295    fn versioning_captures_dropped_messages() {
296        let store: Arc<dyn ContextVersionStore> = Arc::new(InMemoryVersionStore::new());
297        let inner = SlidingWindowTransformer::new(250, 100, 1);
298        let transformer = VersioningTransformer::new(inner, Arc::clone(&store));
299
300        // Each message: 400 chars / 4 = 100 tokens. Budget 250, anchor 1.
301        let body = "x".repeat(400);
302        let mut messages = vec![
303            text_message(&body),
304            text_message(&body),
305            text_message(&body),
306            text_message(&body),
307        ];
308
309        let report = transformer.transform(&mut messages, false);
310        assert!(report.is_some());
311
312        // Messages should be compacted.
313        assert_eq!(messages.len(), 2);
314
315        // A version should have been saved.
316        let versions = store.list_versions();
317        assert_eq!(versions.len(), 1);
318        assert_eq!(versions[0].version, 1);
319        assert_eq!(versions[0].message_count, 2); // 2 messages were dropped
320
321        // Load and verify.
322        let v = store.load_version(1).unwrap();
323        assert_eq!(v.messages.len(), 2);
324        assert!(v.summary.is_none());
325    }
326
327    #[test]
328    fn versioning_with_summarizer() {
329        struct TestSummarizer;
330        impl ContextSummarizer for TestSummarizer {
331            fn summarize(&self, messages: &[LlmMessage]) -> Option<String> {
332                Some(format!("Summary of {} messages", messages.len()))
333            }
334        }
335
336        let store: Arc<dyn ContextVersionStore> = Arc::new(InMemoryVersionStore::new());
337        let inner = SlidingWindowTransformer::new(250, 100, 1);
338        let transformer = VersioningTransformer::new(inner, Arc::clone(&store))
339            .with_summarizer(Arc::new(TestSummarizer));
340
341        let body = "x".repeat(400);
342        let mut messages = vec![
343            text_message(&body),
344            text_message(&body),
345            text_message(&body),
346            text_message(&body),
347        ];
348
349        transformer.transform(&mut messages, false);
350
351        let v = store.load_version(1).unwrap();
352        assert_eq!(v.summary.as_deref(), Some("Summary of 2 messages"));
353    }
354
355    #[test]
356    fn no_compaction_no_version_saved() {
357        let store: Arc<dyn ContextVersionStore> = Arc::new(InMemoryVersionStore::new());
358        let inner = SlidingWindowTransformer::new(10_000, 5_000, 1);
359        let transformer = VersioningTransformer::new(inner, Arc::clone(&store));
360
361        let mut messages = vec![text_message("hello"), text_message("world")];
362        let report = transformer.transform(&mut messages, false);
363
364        assert!(report.is_none());
365        assert!(store.list_versions().is_empty());
366    }
367
368    #[test]
369    fn multiple_compactions_increment_version() {
370        let store: Arc<dyn ContextVersionStore> = Arc::new(InMemoryVersionStore::new());
371        let inner = SlidingWindowTransformer::new(250, 100, 1);
372        let transformer = VersioningTransformer::new(inner, Arc::clone(&store));
373
374        let body = "x".repeat(400);
375
376        // First compaction.
377        let mut messages = vec![
378            text_message(&body),
379            text_message(&body),
380            text_message(&body),
381            text_message(&body),
382        ];
383        transformer.transform(&mut messages, false);
384
385        // Second compaction.
386        let mut messages = vec![
387            text_message(&body),
388            text_message(&body),
389            text_message(&body),
390            text_message(&body),
391        ];
392        transformer.transform(&mut messages, false);
393
394        let versions = store.list_versions();
395        assert_eq!(versions.len(), 2);
396        assert_eq!(versions[0].version, 1);
397        assert_eq!(versions[1].version, 2);
398    }
399
400    #[test]
401    fn latest_version_returns_most_recent() {
402        let store: Arc<dyn ContextVersionStore> = Arc::new(InMemoryVersionStore::new());
403        let inner = SlidingWindowTransformer::new(250, 100, 1);
404        let transformer = VersioningTransformer::new(inner, Arc::clone(&store));
405
406        let body = "x".repeat(400);
407        for _ in 0..3 {
408            let mut messages = vec![
409                text_message(&body),
410                text_message(&body),
411                text_message(&body),
412                text_message(&body),
413            ];
414            transformer.transform(&mut messages, false);
415        }
416
417        let latest = store.latest_version().unwrap();
418        assert_eq!(latest.version, 3);
419    }
420
421    #[test]
422    fn in_memory_store_load_nonexistent() {
423        let store = InMemoryVersionStore::new();
424        assert!(store.load_version(999).is_none());
425        assert!(store.is_empty());
426    }
427
428    #[test]
429    fn version_meta_fields_correct() {
430        let store: Arc<dyn ContextVersionStore> = Arc::new(InMemoryVersionStore::new());
431        let inner = SlidingWindowTransformer::new(250, 100, 1);
432        let transformer = VersioningTransformer::new(inner, Arc::clone(&store));
433
434        let body = "x".repeat(400);
435        let mut messages = vec![
436            text_message(&body),
437            text_message(&body),
438            text_message(&body),
439            text_message(&body),
440        ];
441        transformer.transform(&mut messages, false);
442
443        let meta = &store.list_versions()[0];
444        assert_eq!(meta.version, 1);
445        assert_eq!(meta.turn, 1);
446        assert!(!meta.has_summary);
447        assert!(meta.timestamp > 0);
448        assert_eq!(meta.message_count, 2);
449    }
450
451    #[test]
452    fn store_accessor() {
453        let store: Arc<dyn ContextVersionStore> = Arc::new(InMemoryVersionStore::new());
454        let inner = SlidingWindowTransformer::new(250, 100, 1);
455        let transformer = VersioningTransformer::new(inner, Arc::clone(&store));
456
457        // Verify store() returns the same store.
458        assert!(transformer.store().list_versions().is_empty());
459    }
460
461    // Regression tests for #164: explicit compaction results (no Debug-string diff)
462
463    #[test]
464    fn report_dropped_messages_populated_by_compaction() {
465        // Verify that CompactionReport.dropped_messages contains the correct
466        // messages after compact_sliding_window_with runs — not reconstructed
467        // via Debug-string diffing.
468        use crate::context::compact_sliding_window_with;
469
470        // Each message: 400 chars / 4 = 100 tokens.
471        let body = "x".repeat(400);
472        let mut messages = vec![
473            text_message(&body), // anchor (100t)
474            text_message(&body), // dropped (100t)
475            text_message(&body), // dropped (100t)
476            text_message(&body), // tail (100t)
477        ];
478        // Total: 400t. Budget 250 with anchor=1:
479        // anchor(100t) + tail(100t) = 200t fits; middle 2 dropped.
480        let report = compact_sliding_window_with(&mut messages, 250, 1, None).unwrap();
481
482        // The middle two messages should be in dropped_messages.
483        assert_eq!(report.dropped_messages.len(), 2);
484        // Anchor and tail survive.
485        assert_eq!(messages.len(), 2);
486    }
487
488    #[test]
489    fn versioning_uses_report_dropped_messages_not_debug_diff() {
490        // Verify VersioningTransformer correctly captures dropped content
491        // from the report rather than through snapshot diffing.
492        let store: Arc<dyn ContextVersionStore> = Arc::new(InMemoryVersionStore::new());
493        let inner = SlidingWindowTransformer::new(250, 100, 1);
494        let transformer = VersioningTransformer::new(inner, Arc::clone(&store));
495
496        let body_a = "a".repeat(400); // 100 tokens
497        let body_b = "b".repeat(400); // 100 tokens
498
499        // Messages: anchor(a), dropped(a), dropped(b), tail(b).
500        // Budget 250, anchor=1: anchor(100t) + tail(100t) = 200t fits.
501        // Middle 2 messages (body_a, body_b) dropped.
502        let mut messages = vec![
503            text_message(&body_a),
504            text_message(&body_a),
505            text_message(&body_b),
506            text_message(&body_b),
507        ];
508
509        let report = transformer.transform(&mut messages, false);
510        assert!(report.is_some());
511
512        let v = store.load_version(1).unwrap();
513        // Two middle messages were dropped.
514        assert_eq!(v.messages.len(), 2);
515        // Verify dropped content — if debug-string diffing were used and broke,
516        // the wrong messages would be captured.
517        if let LlmMessage::User(ref u) = v.messages[0] {
518            if let ContentBlock::Text { ref text } = u.content[0] {
519                assert_eq!(text, &body_a);
520            } else {
521                panic!("expected text block");
522            }
523        } else {
524            panic!("expected user message");
525        }
526    }
527
528    #[test]
529    fn custom_messages_excluded_from_dropped_messages() {
530        // Custom messages should be filtered out of CompactionReport.dropped_messages
531        // (they're not LlmMessage variants).
532        use crate::context::compact_sliding_window_with;
533        use crate::types::CustomMessage;
534
535        #[derive(Debug)]
536        struct Marker;
537        impl CustomMessage for Marker {
538            fn as_any(&self) -> &dyn std::any::Any {
539                self
540            }
541        }
542
543        let body = "x".repeat(400); // 100 tokens each
544        let mut messages = vec![
545            text_message(&body),                    // anchor
546            AgentMessage::Custom(Box::new(Marker)), // custom — dropped but excluded
547            text_message(&body),                    // dropped
548            text_message(&body),                    // tail
549        ];
550        // Budget 250, anchor=1: anchor(100t) + tail(100t) fits.
551        let report = compact_sliding_window_with(&mut messages, 250, 1, None).unwrap();
552
553        // Custom message is filtered out; only the LlmMessage is in dropped_messages.
554        assert_eq!(report.dropped_messages.len(), 1);
555    }
556}