Skip to main content

rullama_core/
working_set.rs

1//! Working Set for File Context Management
2//!
3//! Tracks files that are currently "in context" for the AI agent.
4//! Supports LRU-style eviction to prevent context bloat.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10/// Maximum number of files in the working set by default
11pub const DEFAULT_MAX_FILES: usize = 15;
12
13/// Maximum total tokens in working set by default (rough estimate)
14pub const DEFAULT_MAX_TOKENS: usize = 100_000;
15
16/// A file entry in the working set
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct WorkingSetEntry {
19    /// File path.
20    pub path: PathBuf,
21    /// Estimated token count for this file.
22    pub tokens: usize,
23    /// Number of times this file has been accessed.
24    pub access_count: u32,
25    /// Turn number when this file was last accessed.
26    pub last_access_turn: u32,
27    /// Turn number when this file was added.
28    pub added_at_turn: u32,
29    /// Whether this file is pinned (immune to eviction).
30    pub pinned: bool,
31    /// Optional label for categorizing the entry.
32    pub label: Option<String>,
33    /// SHA-256 of the most recent content this agent intended to write to
34    /// `path`, set by `write_file` after its read-back check succeeds.
35    ///
36    /// Used by the validation loop to detect post-validation clobber: if the
37    /// file on disk no longer hashes to `intended_hash` at finalization time,
38    /// another writer has overwritten our content and the agent must NOT
39    /// report `Success: true`.
40    ///
41    /// `None` for files that were only read, or for entries added before any
42    /// write occurred.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub intended_hash: Option<[u8; 32]>,
45}
46
47impl WorkingSetEntry {
48    /// Create a new working set entry at the given turn.
49    pub fn new(path: PathBuf, tokens: usize, current_turn: u32) -> Self {
50        Self {
51            path,
52            tokens,
53            access_count: 1,
54            last_access_turn: current_turn,
55            added_at_turn: current_turn,
56            pinned: false,
57            label: None,
58            intended_hash: None,
59        }
60    }
61
62    /// Attach a label to this entry (builder pattern).
63    pub fn with_label(mut self, label: impl Into<String>) -> Self {
64        self.label = Some(label.into());
65        self
66    }
67
68    /// Mark this entry as pinned (builder pattern).
69    pub fn pinned(mut self) -> Self {
70        self.pinned = true;
71        self
72    }
73}
74
75/// Working set configuration
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct WorkingSetConfig {
78    /// Maximum number of files allowed in the working set.
79    pub max_files: usize,
80    /// Maximum total token count across all files.
81    pub max_tokens: usize,
82    /// Number of turns after which an unpinned file is considered stale.
83    pub stale_after_turns: u32,
84    /// Whether to automatically evict stale files on each turn.
85    pub auto_evict: bool,
86}
87
88impl Default for WorkingSetConfig {
89    fn default() -> Self {
90        Self {
91            max_files: DEFAULT_MAX_FILES,
92            max_tokens: DEFAULT_MAX_TOKENS,
93            stale_after_turns: 10,
94            auto_evict: true,
95        }
96    }
97}
98
99/// Manages the set of files currently in the agent's context
100#[derive(Debug, Clone, Default)]
101pub struct WorkingSet {
102    entries: HashMap<String, WorkingSetEntry>,
103    config: WorkingSetConfig,
104    current_turn: u32,
105    last_eviction: Option<String>,
106}
107
108impl WorkingSet {
109    /// Create a new working set with default configuration.
110    pub fn new() -> Self {
111        Self {
112            entries: HashMap::new(),
113            config: WorkingSetConfig::default(),
114            current_turn: 0,
115            last_eviction: None,
116        }
117    }
118
119    /// Create a new working set with the given configuration.
120    pub fn with_config(config: WorkingSetConfig) -> Self {
121        Self {
122            entries: HashMap::new(),
123            config,
124            current_turn: 0,
125            last_eviction: None,
126        }
127    }
128
129    /// Advance to the next turn, triggering stale eviction if enabled.
130    pub fn next_turn(&mut self) {
131        self.current_turn += 1;
132        if self.config.auto_evict {
133            self.evict_stale();
134        }
135    }
136
137    /// Returns the current turn number.
138    pub fn current_turn(&self) -> u32 {
139        self.current_turn
140    }
141
142    /// Add a file to the working set, evicting LRU entries if needed.
143    pub fn add(&mut self, path: PathBuf, tokens: usize) -> Option<String> {
144        let key = path.to_string_lossy().to_string();
145        if let Some(entry) = self.entries.get_mut(&key) {
146            entry.access_count += 1;
147            entry.last_access_turn = self.current_turn;
148            return None;
149        }
150        let eviction_reason = self.maybe_evict(tokens);
151        let entry = WorkingSetEntry::new(path, tokens, self.current_turn);
152        self.entries.insert(key, entry);
153        eviction_reason
154    }
155
156    /// Add a file with a label, evicting LRU entries if needed.
157    pub fn add_labeled(&mut self, path: PathBuf, tokens: usize, label: &str) -> Option<String> {
158        let key = path.to_string_lossy().to_string();
159        if let Some(entry) = self.entries.get_mut(&key) {
160            entry.access_count += 1;
161            entry.last_access_turn = self.current_turn;
162            entry.label = Some(label.to_string());
163            return None;
164        }
165        let eviction_reason = self.maybe_evict(tokens);
166        let entry = WorkingSetEntry::new(path, tokens, self.current_turn).with_label(label);
167        self.entries.insert(key, entry);
168        eviction_reason
169    }
170
171    /// Add a pinned file that is immune to eviction.
172    pub fn add_pinned(&mut self, path: PathBuf, tokens: usize, label: Option<&str>) {
173        let key = path.to_string_lossy().to_string();
174        if let Some(entry) = self.entries.get_mut(&key) {
175            entry.pinned = true;
176            entry.access_count += 1;
177            entry.last_access_turn = self.current_turn;
178            if let Some(l) = label {
179                entry.label = Some(l.to_string());
180            }
181            return;
182        }
183        let mut entry = WorkingSetEntry::new(path, tokens, self.current_turn).pinned();
184        if let Some(l) = label {
185            entry.label = Some(l.to_string());
186        }
187        self.entries.insert(key, entry);
188    }
189
190    /// Touch a file to update its access count and turn.
191    pub fn touch(&mut self, path: &Path) -> bool {
192        let key = path.to_string_lossy().to_string();
193        if let Some(entry) = self.entries.get_mut(&key) {
194            entry.access_count += 1;
195            entry.last_access_turn = self.current_turn;
196            true
197        } else {
198            false
199        }
200    }
201
202    /// Remove a file from the working set.
203    pub fn remove(&mut self, path: &Path) -> bool {
204        let key = path.to_string_lossy().to_string();
205        self.entries.remove(&key).is_some()
206    }
207
208    /// Pin a file to prevent eviction.
209    pub fn pin(&mut self, path: &Path) -> bool {
210        let key = path.to_string_lossy().to_string();
211        if let Some(entry) = self.entries.get_mut(&key) {
212            entry.pinned = true;
213            true
214        } else {
215            false
216        }
217    }
218
219    /// Unpin a file, allowing it to be evicted.
220    pub fn unpin(&mut self, path: &Path) -> bool {
221        let key = path.to_string_lossy().to_string();
222        if let Some(entry) = self.entries.get_mut(&key) {
223            entry.pinned = false;
224            true
225        } else {
226            false
227        }
228    }
229
230    /// Clear the working set, optionally keeping pinned entries.
231    pub fn clear(&mut self, keep_pinned: bool) {
232        if keep_pinned {
233            self.entries.retain(|_, entry| entry.pinned);
234        } else {
235            self.entries.clear();
236        }
237        self.last_eviction = None;
238    }
239
240    /// Iterate over all entries in the working set.
241    pub fn entries(&self) -> impl Iterator<Item = &WorkingSetEntry> {
242        self.entries.values()
243    }
244
245    /// Get an entry by path.
246    pub fn get(&self, path: &Path) -> Option<&WorkingSetEntry> {
247        let key = path.to_string_lossy().to_string();
248        self.entries.get(&key)
249    }
250
251    /// Check if a path is in the working set.
252    pub fn contains(&self, path: &Path) -> bool {
253        let key = path.to_string_lossy().to_string();
254        self.entries.contains_key(&key)
255    }
256
257    /// Returns the number of entries in the working set.
258    pub fn len(&self) -> usize {
259        self.entries.len()
260    }
261
262    /// Returns true if the working set is empty.
263    pub fn is_empty(&self) -> bool {
264        self.entries.is_empty()
265    }
266
267    /// Returns the total estimated token count across all entries.
268    pub fn total_tokens(&self) -> usize {
269        self.entries.values().map(|e| e.tokens).sum()
270    }
271
272    /// Returns the last eviction message, if any.
273    pub fn last_eviction(&self) -> Option<&str> {
274        self.last_eviction.as_deref()
275    }
276
277    /// Returns all file paths in the working set.
278    pub fn file_paths(&self) -> Vec<&PathBuf> {
279        self.entries.values().map(|e| &e.path).collect()
280    }
281
282    /// Record the SHA-256 of content a tool has just written to `path`.
283    ///
284    /// If the entry is already present, its `intended_hash` is overwritten
285    /// (the most recent write wins) and `last_access_turn` is refreshed.
286    /// If the entry does not exist yet, a new one is inserted with zero
287    /// estimated tokens — the caller is expected to update token estimates
288    /// separately via `add`/`add_labeled` when loading file content.
289    pub fn record_write(&mut self, path: &Path, hash: [u8; 32]) {
290        let key = path.to_string_lossy().to_string();
291        if let Some(entry) = self.entries.get_mut(&key) {
292            entry.intended_hash = Some(hash);
293            entry.access_count += 1;
294            entry.last_access_turn = self.current_turn;
295            return;
296        }
297        let mut entry = WorkingSetEntry::new(path.to_path_buf(), 0, self.current_turn);
298        entry.intended_hash = Some(hash);
299        self.entries.insert(key, entry);
300    }
301
302    /// Return the intended-write SHA-256 previously recorded for `path`, if
303    /// any.  `None` if the path is not tracked or was never written.
304    pub fn get_intended_hash(&self, path: &Path) -> Option<[u8; 32]> {
305        let key = path.to_string_lossy().to_string();
306        self.entries.get(&key).and_then(|e| e.intended_hash)
307    }
308
309    fn evict_stale(&mut self) {
310        let stale_threshold = self
311            .current_turn
312            .saturating_sub(self.config.stale_after_turns);
313        let before_count = self.entries.len();
314        self.entries
315            .retain(|_, entry| entry.pinned || entry.last_access_turn > stale_threshold);
316        let evicted = before_count - self.entries.len();
317        if evicted > 0 {
318            self.last_eviction = Some(format!("Evicted {} stale file(s)", evicted));
319        }
320    }
321
322    fn maybe_evict(&mut self, new_tokens: usize) -> Option<String> {
323        let mut evicted_files = Vec::new();
324        while self.entries.len() >= self.config.max_files {
325            if let Some(key) = self.find_lru_candidate() {
326                if let Some(entry) = self.entries.remove(&key) {
327                    evicted_files.push(entry.path.to_string_lossy().to_string());
328                }
329            } else {
330                break;
331            }
332        }
333        while self.total_tokens() + new_tokens > self.config.max_tokens {
334            if let Some(key) = self.find_lru_candidate() {
335                if let Some(entry) = self.entries.remove(&key) {
336                    evicted_files.push(entry.path.to_string_lossy().to_string());
337                }
338            } else {
339                break;
340            }
341        }
342        if evicted_files.is_empty() {
343            None
344        } else {
345            let reason = format!("Evicted: {}", evicted_files.join(", "));
346            self.last_eviction = Some(reason.clone());
347            Some(reason)
348        }
349    }
350
351    fn find_lru_candidate(&self) -> Option<String> {
352        self.entries
353            .iter()
354            .filter(|(_, entry)| !entry.pinned)
355            .min_by_key(|(_, entry)| (entry.last_access_turn, entry.access_count))
356            .map(|(key, _)| key.clone())
357    }
358}
359
360/// Estimate tokens for a string (rough: ~4 chars per token)
361pub fn estimate_tokens(content: &str) -> usize {
362    content.len().div_ceil(4)
363}
364
365/// Estimate tokens for a file by size
366pub fn estimate_tokens_from_size(bytes: u64) -> usize {
367    (bytes as usize).div_ceil(4)
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    #[test]
375    fn test_working_set_add_and_access() {
376        let mut ws = WorkingSet::new();
377        ws.add(PathBuf::from("/test/file1.rs"), 1000);
378        assert_eq!(ws.len(), 1);
379        assert!(ws.contains(&PathBuf::from("/test/file1.rs")));
380    }
381
382    #[test]
383    fn test_working_set_lru_eviction() {
384        let config = WorkingSetConfig {
385            max_files: 3,
386            max_tokens: 100_000,
387            stale_after_turns: 10,
388            auto_evict: false,
389        };
390        let mut ws = WorkingSet::with_config(config);
391        ws.add(PathBuf::from("/test/file1.rs"), 100);
392        ws.next_turn();
393        ws.add(PathBuf::from("/test/file2.rs"), 100);
394        ws.next_turn();
395        ws.add(PathBuf::from("/test/file3.rs"), 100);
396        ws.next_turn();
397        ws.add(PathBuf::from("/test/file4.rs"), 100);
398        assert_eq!(ws.len(), 3);
399        assert!(!ws.contains(&PathBuf::from("/test/file1.rs")));
400    }
401
402    #[test]
403    fn test_estimate_tokens() {
404        assert_eq!(estimate_tokens(""), 0);
405        assert_eq!(estimate_tokens("test"), 1);
406    }
407
408    #[test]
409    fn test_working_set_records_and_retrieves_hash() {
410        let mut ws = WorkingSet::new();
411        let path = PathBuf::from("/tmp/claim.txt");
412        let hash = [7u8; 32];
413        ws.record_write(&path, hash);
414        assert_eq!(ws.get_intended_hash(&path), Some(hash));
415        assert!(
416            ws.get_intended_hash(&PathBuf::from("/tmp/other.txt"))
417                .is_none(),
418            "unrecorded path must return None"
419        );
420
421        // Overwriting an existing entry's hash must replace it, not
422        // merely add a new entry.
423        let newer = [42u8; 32];
424        ws.record_write(&path, newer);
425        assert_eq!(ws.get_intended_hash(&path), Some(newer));
426        assert_eq!(ws.len(), 1);
427    }
428}