Skip to main content

agent_engine/memory_backend/
history.rs

1//! The context archives of a binding's project: the file store, reached on
2//! a blocking worker. Projection is host-screened.
3use super::{error, MemoryBinding, Result};
4use crate::SharedMessage;
5use agent_core::context_archive::{
6    self, ArchiveDescriptor, ArchivePart, ArchiveRedactor, ArchiveRef, ArchiveStore,
7    ArchivedMessage, Forgotten, Unscanned,
8};
9use serde_json::Value;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::Arc;
12
13fn redactor() -> Arc<ArchiveRedactor> {
14    Arc::new(|text| {
15        let mut value = serde_json::from_str(text).unwrap_or_else(|_| Value::String(text.into()));
16        crate::runtime::trace::export::redact_value(&mut value);
17        match value {
18            Value::String(s) => s,
19            other => other.to_string(),
20        }
21    })
22}
23fn valid_id(id: &str) -> bool {
24    id.len() == 32 && id.bytes().all(|b| b.is_ascii_hexdigit())
25}
26/// The file store opened for a read: fetch, note or search. Unlike
27/// `ArchiveStore::new`, which a seal and a forget go through, this creates no
28/// directory, changes no mode and syncs nothing. `None` when the project has
29/// no archive directory: there is nothing in it to find.
30fn reader(
31    base: &std::path::Path,
32    scope_key: &str,
33    abort: Arc<AtomicBool>,
34) -> std::io::Result<Option<ArchiveStore>> {
35    let store = ArchiveStore::open_existing(base, scope_key, "context-windows-v1")?;
36    Ok(store.map(|store| store.with_abort(abort)))
37}
38/// What a fetch from a project without archives is told: what the store
39/// says of an archive it does not have.
40fn no_archive() -> std::io::Error {
41    std::io::Error::new(std::io::ErrorKind::NotFound, context_archive::NOT_FOUND)
42}
43/// Run one local archive operation on a blocking worker. The archive queues
44/// operations within this process, so a worker that outlives its dropped
45/// caller delays later operations rather than making them fail as busy.
46///
47/// Dropping the caller sets the abort flag. A worker that sees it before it
48/// starts does nothing; one that sees it while waiting for admission reads and
49/// writes no records, although for a seal or a forget opening the store
50/// (creating the private directories) and projecting may already have
51/// happened. A read creates nothing at any point. Once admitted an operation
52/// always completes, so an abandoned seal may still be published.
53async fn archive_worker<T: Send + 'static>(
54    work: impl FnOnce(Arc<AtomicBool>) -> std::io::Result<T> + Send + 'static,
55) -> Result<T> {
56    struct AbortOnDrop(Arc<AtomicBool>);
57    impl Drop for AbortOnDrop {
58        fn drop(&mut self) {
59            self.0.store(true, Ordering::Release);
60        }
61    }
62    let abort = AbortOnDrop(Arc::new(AtomicBool::new(false)));
63    let flag = abort.0.clone();
64    tokio::task::spawn_blocking(move || {
65        if flag.load(Ordering::Acquire) {
66            return Err(std::io::Error::new(
67                std::io::ErrorKind::Interrupted,
68                "archive operation canceled before it started",
69            ));
70        }
71        work(flag)
72    })
73    .await
74    .map_err(|_| error("history worker failed"))?
75    .map_err(|e| error(e.to_string()))
76}
77impl MemoryBinding {
78    pub async fn history_seal(
79        &self,
80        logical_id: &str,
81        messages: &[SharedMessage],
82        note: &str,
83    ) -> Result<(ArchiveRef, String)> {
84        let scope = self.scope()?.clone();
85        let base = self.base().to_owned();
86        let logical = logical_id.to_owned();
87        let source = messages.to_vec();
88        let note = note.to_owned();
89        // One locked operation: the stored note comes from the same scan.
90        archive_worker(move |abort| {
91            ArchiveStore::new(&base, scope.key(), &logical)?
92                .with_redactor(redactor())
93                .with_abort(abort)
94                .seal_with_note(&source, &note)
95        })
96        .await
97    }
98    /// [`history_seal`](Self::history_seal) for a whole window, as rollover
99    /// needs it: the archives the window was sealed as ("parts", in source
100    /// order, never empty) and the note stored with the last one.
101    ///
102    /// The store never fails this for a window over a per-archive limit
103    /// ([`ArchiveStore::seal_window`]): it cuts the window into several
104    /// archives, and a window that fits is the one archive `history_seal`
105    /// makes. The archives record nothing about each other, so the returned
106    /// list is the only record of a split. Parts are sealed one at a time: a
107    /// failure, or a caller dropped between two of them, leaves the earlier
108    /// ones on disk and returns no list; sealing the same window again finds
109    /// them and returns the same list.
110    pub async fn history_seal_window(
111        &self,
112        logical_id: &str,
113        messages: &[SharedMessage],
114        note: &str,
115    ) -> Result<(Vec<ArchivePart>, String)> {
116        let scope = self.scope()?.clone();
117        let base = self.base().to_owned();
118        let logical = logical_id.to_owned();
119        let source = messages.to_vec();
120        let note = note.to_owned();
121        archive_worker(move |abort| {
122            let window = ArchiveStore::new(&base, scope.key(), &logical)?
123                .with_redactor(redactor())
124                .with_abort(abort)
125                .seal_window(&source, &note)?;
126            Ok((window.parts, window.note))
127        })
128        .await
129    }
130    pub async fn history_search(
131        &self,
132        query: &str,
133        limit: usize,
134    ) -> Result<Vec<ArchiveDescriptor>> {
135        if query.len() > 512 || limit > 32 {
136            return Err(error("history search exceeds bounds"));
137        }
138        let scope = self.scope()?.clone();
139        let base = self.base().to_owned();
140        let query = query.to_owned();
141        archive_worker(move |abort| match reader(&base, scope.key(), abort)? {
142            Some(store) => store.search(&query, limit),
143            None => Ok(Vec::new()),
144        })
145        .await
146    }
147    /// [`history_search`](Self::history_search) that also reports what the
148    /// store did not read: `None` when nothing is missing, otherwise an
149    /// [`Unscanned`] that says whether the scan budget stopped it
150    /// (`stopped_after`) and whether a shelf, a legacy slot or a file was
151    /// passed over because it could not be read (`unread`).
152    pub async fn history_search_scan(
153        &self,
154        query: &str,
155        limit: usize,
156    ) -> Result<(Vec<ArchiveDescriptor>, Option<Unscanned>)> {
157        if query.len() > 512 || limit > 32 {
158            return Err(error("history search exceeds bounds"));
159        }
160        let scope = self.scope()?.clone();
161        let base = self.base().to_owned();
162        let query = query.to_owned();
163        archive_worker(move |abort| match reader(&base, scope.key(), abort)? {
164            Some(store) => store.search_scan(&query, limit),
165            None => Ok((Vec::new(), None)),
166        })
167        .await
168    }
169    pub async fn history_fetch(
170        &self,
171        id: &str,
172        start: usize,
173        limit: usize,
174    ) -> Result<Vec<ArchivedMessage>> {
175        if !valid_id(id) || limit > 128 {
176            return Err(error("invalid history range"));
177        }
178        let scope = self.scope()?.clone();
179        let base = self.base().to_owned();
180        let id = id.to_owned();
181        archive_worker(move |abort| {
182            reader(&base, scope.key(), abort)?
183                .ok_or_else(no_archive)?
184                .fetch_with_provenance(&id, start, limit)
185        })
186        .await
187    }
188    pub async fn history_note(&self, id: &str) -> Result<String> {
189        if !valid_id(id) {
190            return Err(error("invalid history id"));
191        }
192        let scope = self.scope()?.clone();
193        let base = self.base().to_owned();
194        let id = id.to_owned();
195        archive_worker(move |abort| {
196            reader(&base, scope.key(), abort)?
197                .ok_or_else(no_archive)?
198                .fetch_note(&id)
199        })
200        .await
201    }
202    /// Forget the archived window `id`. The store forgets by content:
203    /// every file that holds the same window becomes a tombstone, and what
204    /// comes back names each one ([`Forgotten::ids`]) and says whether a
205    /// place that may hold another copy could not be read
206    /// ([`Forgotten::unread`]); such a copy is still live.
207    pub async fn history_forget(&self, id: &str) -> Result<Forgotten> {
208        if !valid_id(id) {
209            return Err(error("invalid history id"));
210        }
211        let scope = self.scope()?.clone();
212        let base = self.base().to_owned();
213        let id = id.to_owned();
214        archive_worker(move |abort| {
215            ArchiveStore::new(&base, scope.key(), "context-windows-v1")?
216                .with_abort(abort)
217                .forget(&id)
218        })
219        .await
220    }
221}
222
223#[cfg(test)]
224mod budget_tests {
225    use super::*;
226    use agent_core::memory::store::ProjectScope;
227    use serde_json::json;
228    use std::path::Path;
229
230    fn binding(root: &Path) -> MemoryBinding {
231        MemoryBinding::new(root.into(), Ok(ProjectScope::for_root(root).unwrap()))
232    }
233
234    async fn roundtrip(binding: &MemoryBinding) {
235        let messages = vec![
236            Arc::new(json!({"role":"system","content":"x".repeat(9 * 1024 * 1024)})),
237            Arc::new(
238                json!({"role":"assistant","metadata":"x".repeat(9 * 1024 * 1024),"content":[
239                    {"type":"text","text":"exact source αβ\n"},
240                    {"type":"image","source":{"data":"x".repeat(1024 * 1024)}}
241                ]}),
242            ),
243            Arc::new(json!({"role":"user","content":[{
244                "type":"tool_result","tool_use_id":"r1","content":"exact tool log\n"
245            }]})),
246        ];
247        let original = messages.clone();
248        let (receipt, note) = binding
249            .history_seal("budget-session", &messages, "bounded note")
250            .await
251            .unwrap();
252        assert_eq!(receipt.source_message_count, 3);
253        assert_eq!(receipt.message_count, 2);
254        assert_eq!(note, "bounded note");
255        let fetched = binding.history_fetch(&receipt.id, 0, 8).await.unwrap();
256        assert_eq!(fetched[0].source_index, 1);
257        assert_eq!(fetched[0].block_indices, vec![0]);
258        assert_eq!(
259            fetched[0].message["content"][0]["text"],
260            "exact source αβ\n"
261        );
262        assert_eq!(fetched[1].message, messages[2]);
263        assert_eq!(messages, original);
264        let (retry, retry_note) = binding
265            .history_seal("budget-session", &messages, "different note")
266            .await
267            .unwrap();
268        assert_eq!(retry.id, receipt.id);
269        // A deduplicated seal reports the note actually stored with the segment.
270        assert_eq!(retry_note, "bounded note");
271        let before = binding.history_search("", 8).await.unwrap();
272        let too_big = vec![Arc::new(
273            json!({"role":"assistant", "content":"x".repeat(context_archive::MAX_INPUT_BYTES + 1)}),
274        )];
275        let err = binding
276            .history_seal("budget-session", &too_big, "")
277            .await
278            .unwrap_err();
279        assert!(err.to_string().contains("input byte budget exceeded"));
280        assert_eq!(binding.history_search("", 8).await.unwrap(), before);
281        binding.history_forget(&receipt.id).await.unwrap();
282        assert!(binding
283            .history_seal("budget-session", &messages, "bounded note")
284            .await
285            .is_err());
286    }
287
288    #[tokio::test]
289    async fn legacy_history_eligible_budget_roundtrip_and_atomic_refusal() {
290        let temp = tempfile::tempdir().unwrap();
291        roundtrip(&binding(temp.path())).await;
292    }
293
294    /// The file store keeps every archive: the 129th seal in a project used to
295    /// fail with "archive segment budget exhausted".
296    #[tokio::test]
297    async fn legacy_history_seals_past_128_archives_and_reports_a_complete_search() {
298        let temp = tempfile::tempdir().unwrap();
299        let binding = binding(temp.path());
300        let mut ids = Vec::new();
301        for index in 0..129 {
302            let messages = vec![Arc::new(
303                json!({"role":"user","content":format!("window {index}.")}),
304            )];
305            let (reference, _) = binding
306                .history_seal("synthetic-session", &messages, "")
307                .await
308                .unwrap();
309            ids.push(reference.id);
310        }
311        for id in &ids {
312            assert_eq!(binding.history_fetch(id, 0, 1).await.unwrap().len(), 1);
313        }
314        ids.sort();
315        ids.dedup();
316        assert_eq!(ids.len(), 129);
317        // The oldest is found and nothing was left unread.
318        let (rows, stopped_after) = binding.history_search_scan("window 0.", 32).await.unwrap();
319        assert_eq!(rows.len(), 1);
320        assert_eq!(stopped_after, None);
321        assert_eq!(
322            binding.history_fetch(&rows[0].id, 0, 1).await.unwrap()[0].message["content"],
323            "window 0."
324        );
325        let (rows, stopped_after) = binding.history_search_scan("", 32).await.unwrap();
326        assert_eq!((rows.len(), stopped_after), (32, None));
327        assert_eq!(binding.history_search("", 32).await.unwrap(), rows);
328        assert!(binding.history_search_scan("", 33).await.is_err());
329        assert!(binding.history_search_scan(&"q".repeat(513), 1).await.is_err());
330    }
331
332    /// A history read with the file backend creates nothing: not for a
333    /// project that has never archived anything, and not on the first read
334    /// of one that has. Opening the store as a seal does made the base,
335    /// `context-archives` and project directories and set their modes on
336    /// every call.
337    #[cfg(unix)]
338    #[tokio::test]
339    async fn legacy_history_reads_create_nothing_and_change_nothing() {
340        use std::os::unix::fs::MetadataExt;
341        let temp = tempfile::tempdir().unwrap();
342        let root = temp.path().to_path_buf();
343        let binding = binding(&root);
344        let missing = "a".repeat(32);
345        let not_there = context_archive::NOT_FOUND;
346        // No archive yet: nothing is made to find that out.
347        assert!(binding.history_search("", 8).await.unwrap().is_empty());
348        let (rows, stopped_after) = binding.history_search_scan("", 8).await.unwrap();
349        assert!(rows.is_empty() && stopped_after.is_none());
350        let error = binding.history_fetch(&missing, 0, 1).await.unwrap_err();
351        assert!(error.to_string().contains(not_there), "{error}");
352        let error = binding.history_note(&missing).await.unwrap_err();
353        assert!(error.to_string().contains(not_there), "{error}");
354        assert_eq!(std::fs::read_dir(&root).unwrap().count(), 0);
355        let message = vec![Arc::new(json!({"role":"user","content":"kept words"}))];
356        let (reference, _) = binding
357            .history_seal("synthetic-session", &message, "kept note")
358            .await
359            .unwrap();
360        // Mode, change time and modification time of everything there is.
361        let stamps = || {
362            let mut found = Vec::new();
363            let mut pending = vec![root.clone()];
364            while let Some(path) = pending.pop() {
365                let meta = std::fs::symlink_metadata(&path).unwrap();
366                if meta.is_dir() {
367                    let entries = std::fs::read_dir(&path).unwrap();
368                    pending.extend(entries.map(|entry| entry.unwrap().path()));
369                }
370                let times = (meta.ctime(), meta.ctime_nsec(), meta.mtime(), meta.mtime_nsec());
371                found.push((path, meta.mode(), times));
372            }
373            found.sort();
374            found
375        };
376        // Whatever is changed from here on gets a later change time.
377        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
378        let before = stamps();
379        assert_eq!(before.len(), 6, "base, context-archives, project, lock, shelf, archive");
380        let fetched = binding.history_fetch(&reference.id, 0, 1).await.unwrap();
381        assert_eq!(fetched[0].message["content"], "kept words");
382        assert_eq!(binding.history_note(&reference.id).await.unwrap(), "kept note");
383        assert_eq!(binding.history_search("kept", 8).await.unwrap().len(), 1);
384        let (rows, _) = binding.history_search_scan("", 8).await.unwrap();
385        assert_eq!(rows.len(), 1);
386        // The same answer as from a project with no archive at all.
387        let error = binding.history_fetch(&missing, 0, 1).await.unwrap_err();
388        assert!(error.to_string().contains(not_there), "{error}");
389        assert_eq!(stamps(), before);
390    }
391
392    fn lines(count: usize) -> Vec<SharedMessage> {
393        (0..count)
394            .map(|index| Arc::new(json!({"role":"user","content":format!("line {index}.")})))
395            .collect()
396    }
397
398    /// The file store through `history_seal_window`: a window within the
399    /// limits of one archive is the archive `history_seal` makes, and a
400    /// window over them, which `history_seal` still refuses, is several.
401    #[tokio::test]
402    async fn legacy_history_seal_window_splits_what_history_seal_refuses() {
403        let temp = tempfile::tempdir().unwrap();
404        let binding = binding(temp.path());
405        let small = lines(3);
406        let (parts, note) = binding
407            .history_seal_window("window-session", &small, "the note")
408            .await
409            .unwrap();
410        let (reference, stored) = binding
411            .history_seal("window-session", &small, "another note")
412            .await
413            .unwrap();
414        let whole = ArchivePart {
415            archive: reference,
416            first_source_index: 0,
417            reduced_messages: 0,
418        };
419        assert_eq!(parts, [whole]);
420        assert_eq!((note.as_str(), stored.as_str()), ("the note", "the note"));
421        assert_eq!(binding.history_search("", 8).await.unwrap().len(), 1);
422
423        let large = lines(context_archive::MAX_MESSAGES + 1);
424        let refused = binding
425            .history_seal("window-session", &large, "")
426            .await
427            .unwrap_err();
428        assert!(refused.to_string().contains("budget exceeded"), "{refused}");
429        assert_eq!(binding.history_search("", 8).await.unwrap().len(), 1);
430        let (parts, note) = binding
431            .history_seal_window("window-session", &large, "the note")
432            .await
433            .unwrap();
434        assert_eq!(parts.len(), 2, "{parts:?}");
435        assert_eq!(note, "the note");
436        let mut covered = 0;
437        for part in &parts {
438            assert_eq!(part.first_source_index, covered);
439            assert_eq!(part.reduced_messages, 0);
440            // Inside a part, source indices start again at 0.
441            let first = binding.history_fetch(&part.archive.id, 0, 1).await.unwrap();
442            assert_eq!(first[0].source_index, 0);
443            assert_eq!(first[0].message, large[covered]);
444            covered += part.archive.source_message_count;
445        }
446        assert_eq!(covered, large.len());
447        // The note is with the last part, and the same window again is the
448        // same list, with the note that was stored.
449        assert_eq!(binding.history_note(&parts[0].archive.id).await.unwrap(), "");
450        assert_eq!(binding.history_note(&parts[1].archive.id).await.unwrap(), "the note");
451        let again = binding
452            .history_seal_window("window-session", &large, "a later note")
453            .await
454            .unwrap();
455        assert_eq!(again, (parts, "the note".to_owned()));
456        assert_eq!(binding.history_search("", 8).await.unwrap().len(), 3);
457    }
458
459    /// Parallel history tools and rollovers share one archive directory per
460    /// project; in-process overlap must queue, never surface "archive busy".
461    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
462    async fn concurrent_legacy_history_operations_queue_instead_of_reporting_busy() {
463        let temp = tempfile::tempdir().unwrap();
464        let binding = binding(temp.path());
465        let tasks: Vec<_> = (0..8)
466            .map(|worker| {
467                let binding = binding.clone();
468                tokio::spawn(async move {
469                    for round in 0..6 {
470                        let text = format!("worker {worker} round {round}");
471                        let messages = vec![Arc::new(json!({"role":"user","content":text}))];
472                        let logical = format!("synthetic-session-{worker}");
473                        let (reference, note) =
474                            binding.history_seal(&logical, &messages, "note").await?;
475                        assert_eq!(note, "note");
476                        assert_eq!(binding.history_fetch(&reference.id, 0, 1).await?.len(), 1);
477                        binding.history_search("worker", 4).await?;
478                    }
479                    Ok::<_, crate::RuntimeError>(())
480                })
481            })
482            .collect();
483        for task in tasks {
484            task.await.unwrap().unwrap();
485        }
486        assert_eq!(binding.history_search("", 32).await.unwrap().len(), 32);
487    }
488
489    /// A caller dropped while its operation waits (e.g. a canceled rollover
490    /// queued behind a history read) must not start it later.
491    #[tokio::test]
492    async fn a_dropped_caller_aborts_its_archive_operation_before_it_starts() {
493        let (entered_tx, entered_rx) = std::sync::mpsc::channel();
494        let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
495        let (seen_tx, seen_rx) = std::sync::mpsc::channel();
496        let call = archive_worker(move |abort| {
497            entered_tx.send(()).unwrap();
498            release_rx.recv().unwrap();
499            seen_tx.send(abort.load(Ordering::Acquire)).unwrap();
500            Ok(())
501        });
502        tokio::select! {
503            biased;
504            _ = tokio::task::spawn_blocking(move || entered_rx.recv().unwrap()) => {}
505            _ = call => panic!("worker cannot finish before release"),
506        }
507        // `call` is dropped; the worker is still running and must see it.
508        release_tx.send(()).unwrap();
509        assert!(tokio::task::spawn_blocking(move || seen_rx.recv().unwrap())
510            .await
511            .unwrap());
512
513        let completed = archive_worker(|abort| Ok(abort.load(Ordering::Acquire)))
514            .await
515            .unwrap();
516        assert!(!completed, "an awaited operation is never aborted");
517    }
518}