Skip to main content

agent_engine/memory_backend/
history.rs

1//! One history authority behind the selected backend. Projection is pure and
2//! host-screened; selecting Axel never creates a legacy context archive.
3use super::{error, MemoryBinding, Result};
4use crate::SharedMessage;
5use agent_core::context_archive::{
6    self, ArchiveDescriptor, ArchiveRedactor, ArchiveRef, ArchiveStore, ArchivedMessage,
7};
8use serde_json::{json, Value};
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::sync::Arc;
11
12fn redactor() -> Arc<ArchiveRedactor> {
13    Arc::new(|text| {
14        let mut value = serde_json::from_str(text).unwrap_or_else(|_| Value::String(text.into()));
15        crate::runtime::trace::export::redact_value(&mut value);
16        match value {
17            Value::String(s) => s,
18            other => other.to_string(),
19        }
20    })
21}
22fn valid_id(id: &str) -> bool {
23    id.len() == 32 && id.bytes().all(|b| b.is_ascii_hexdigit())
24}
25/// Run one local archive operation on a blocking worker. The archive queues
26/// operations within this process, so a worker that outlives its dropped
27/// caller delays later operations rather than making them fail as busy.
28///
29/// Dropping the caller sets the abort flag. A worker that sees it before it
30/// starts does nothing; one that sees it while waiting for admission reads and
31/// writes no records, although opening the store (creating the private
32/// directories) and projecting a seal may already have happened. Once admitted
33/// an operation always completes, so an abandoned seal may still be published.
34async fn archive_worker<T: Send + 'static>(
35    work: impl FnOnce(Arc<AtomicBool>) -> std::io::Result<T> + Send + 'static,
36) -> Result<T> {
37    struct AbortOnDrop(Arc<AtomicBool>);
38    impl Drop for AbortOnDrop {
39        fn drop(&mut self) {
40            self.0.store(true, Ordering::Release);
41        }
42    }
43    let abort = AbortOnDrop(Arc::new(AtomicBool::new(false)));
44    let flag = abort.0.clone();
45    tokio::task::spawn_blocking(move || {
46        if flag.load(Ordering::Acquire) {
47            return Err(std::io::Error::new(
48                std::io::ErrorKind::Interrupted,
49                "archive operation canceled before it started",
50            ));
51        }
52        work(flag)
53    })
54    .await
55    .map_err(|_| error("history worker failed"))?
56    .map_err(|e| error(e.to_string()))
57}
58impl MemoryBinding {
59    pub async fn history_seal(
60        &self,
61        logical_id: &str,
62        messages: &[SharedMessage],
63        note: &str,
64    ) -> Result<(ArchiveRef, String)> {
65        let scope = self.scope()?.clone();
66        if !self.exclusive() {
67            let base = self.base().to_owned();
68            let logical = logical_id.to_owned();
69            let source = messages.to_vec();
70            let note = note.to_owned();
71            // One locked operation: the stored note comes from the same scan.
72            return archive_worker(move |abort| {
73                ArchiveStore::new(&base, scope.key(), &logical)?
74                    .with_redactor(redactor())
75                    .with_abort(abort)
76                    .seal_with_note(&source, &note)
77            })
78            .await;
79        }
80        let projection =
81            context_archive::project_archive(logical_id, messages, note, Some(redactor()))
82                .map_err(|e| error(e.to_string()))?;
83        let count = projection.messages.len();
84        let source_count = projection.source_message_count;
85        let value = self
86            .rpc(
87                "history_seal",
88                serde_json::to_value(projection).map_err(|_| error("history encoding failed"))?,
89            )
90            .await?;
91        let r:ArchiveRef=serde_json::from_value(json!({"id":value["id"],"message_count":value["message_count"],"source_message_count":value["source_message_count"]})).map_err(|_|error("invalid history commit acknowledgement"))?;
92        let stored = value["note"]
93            .as_str()
94            .ok_or_else(|| error("history commit missing note"))?
95            .to_owned();
96        if !valid_id(&r.id)
97            || r.message_count != count
98            || r.source_message_count != source_count
99            || stored.len() > 8192
100        {
101            return Err(error("history commit acknowledgement mismatch"));
102        }
103        Ok((r, stored))
104    }
105    pub async fn history_search(
106        &self,
107        query: &str,
108        limit: usize,
109    ) -> Result<Vec<ArchiveDescriptor>> {
110        if query.len() > 512 || limit > 32 {
111            return Err(error("history search exceeds bounds"));
112        }
113        let scope = self.scope()?.clone();
114        if !self.exclusive() {
115            let base = self.base().to_owned();
116            let query = query.to_owned();
117            return archive_worker(move |abort| {
118                ArchiveStore::new(&base, scope.key(), "context-windows-v1")?
119                    .with_abort(abort)
120                    .search(&query, limit)
121            })
122            .await;
123        }
124        let rows: Vec<ArchiveDescriptor> = serde_json::from_value(
125            self.rpc("history_search", json!({"query":query,"limit":limit}))
126                .await?,
127        )
128        .map_err(|_| error("invalid history descriptors"))?;
129        if rows.len() > limit
130            || rows.iter().any(|r| {
131                !valid_id(&r.id)
132                    || r.snippet.len() > 256
133                    || r.message_count > 4096
134                    || r.source_message_count > 4096
135            })
136        {
137            return Err(error("history descriptors exceed bounds"));
138        }
139        Ok(rows)
140    }
141    pub async fn history_fetch(
142        &self,
143        id: &str,
144        start: usize,
145        limit: usize,
146    ) -> Result<Vec<ArchivedMessage>> {
147        if !valid_id(id) || limit > 128 {
148            return Err(error("invalid history range"));
149        }
150        let scope = self.scope()?.clone();
151        if !self.exclusive() {
152            let base = self.base().to_owned();
153            let id = id.to_owned();
154            return archive_worker(move |abort| {
155                ArchiveStore::new(&base, scope.key(), "context-windows-v1")?
156                    .with_abort(abort)
157                    .fetch_with_provenance(&id, start, limit)
158            })
159            .await;
160        }
161        let rows: Vec<ArchivedMessage> = serde_json::from_value(
162            self.rpc(
163                "history_fetch",
164                json!({"id":id,"start":start,"limit":limit}),
165            )
166            .await?,
167        )
168        .map_err(|_| error("invalid history fetch"))?;
169        if rows.len() > limit || rows.iter().any(|r| r.source_index >= 4096) {
170            return Err(error("history range exceeds bounds"));
171        }
172        Ok(rows)
173    }
174    pub async fn history_note(&self, id: &str) -> Result<String> {
175        if !valid_id(id) {
176            return Err(error("invalid history id"));
177        }
178        let scope = self.scope()?.clone();
179        if !self.exclusive() {
180            let base = self.base().to_owned();
181            let id = id.to_owned();
182            return archive_worker(move |abort| {
183                ArchiveStore::new(&base, scope.key(), "context-windows-v1")?
184                    .with_abort(abort)
185                    .fetch_note(&id)
186            })
187            .await;
188        }
189        let note: String =
190            serde_json::from_value(self.rpc("history_note", json!({"id":id})).await?)
191                .map_err(|_| error("invalid history note"))?;
192        if note.len() > 8192 {
193            return Err(error("history note exceeds bounds"));
194        }
195        Ok(note)
196    }
197    pub async fn history_forget(&self, id: &str) -> Result<()> {
198        if !valid_id(id) {
199            return Err(error("invalid history id"));
200        }
201        let scope = self.scope()?.clone();
202        if !self.exclusive() {
203            let base = self.base().to_owned();
204            let id = id.to_owned();
205            return archive_worker(move |abort| {
206                ArchiveStore::new(&base, scope.key(), "context-windows-v1")?
207                    .with_abort(abort)
208                    .forget(&id)
209            })
210            .await;
211        }
212        if self.rpc("history_forget", json!({"id":id})).await? != true {
213            return Err(error("history not found in project"));
214        }
215        Ok(())
216    }
217}
218
219#[cfg(test)]
220mod budget_tests {
221    use super::*;
222    use agent_core::config::{MemoryBackendConfig, MemoryBackendKind};
223    use agent_core::memory::store::ProjectScope;
224    use std::path::Path;
225
226    fn binding(
227        root: &Path,
228        kind: MemoryBackendKind,
229        exe: Option<std::path::PathBuf>,
230    ) -> MemoryBinding {
231        MemoryBinding::new(
232            root.into(),
233            Ok(ProjectScope::for_root(root).unwrap()),
234            &MemoryBackendConfig {
235                kind,
236                executable: exe,
237                brain: Some(root.join("brain.r8")),
238                user_scope: false,
239            },
240        )
241    }
242
243    async fn roundtrip(binding: &MemoryBinding) {
244        let messages = vec![
245            Arc::new(json!({"role":"system","content":"x".repeat(9 * 1024 * 1024)})),
246            Arc::new(
247                json!({"role":"assistant","metadata":"x".repeat(9 * 1024 * 1024),"content":[
248                    {"type":"text","text":"exact source αβ\n"},
249                    {"type":"image","source":{"data":"x".repeat(1024 * 1024)}}
250                ]}),
251            ),
252            Arc::new(json!({"role":"user","content":[{
253                "type":"tool_result","tool_use_id":"r1","content":"exact tool log\n"
254            }]})),
255        ];
256        let original = messages.clone();
257        let (receipt, note) = binding
258            .history_seal("budget-session", &messages, "bounded note")
259            .await
260            .unwrap();
261        assert_eq!(receipt.source_message_count, 3);
262        assert_eq!(receipt.message_count, 2);
263        assert_eq!(note, "bounded note");
264        let fetched = binding.history_fetch(&receipt.id, 0, 8).await.unwrap();
265        assert_eq!(fetched[0].source_index, 1);
266        assert_eq!(fetched[0].block_indices, vec![0]);
267        assert_eq!(
268            fetched[0].message["content"][0]["text"],
269            "exact source αβ\n"
270        );
271        assert_eq!(fetched[1].message, messages[2]);
272        assert_eq!(messages, original);
273        let (retry, retry_note) = binding
274            .history_seal("budget-session", &messages, "different note")
275            .await
276            .unwrap();
277        assert_eq!(retry.id, receipt.id);
278        // A deduplicated seal reports the note actually stored with the segment.
279        assert_eq!(retry_note, "bounded note");
280        let before = binding.history_search("", 8).await.unwrap();
281        let too_big = vec![Arc::new(
282            json!({"role":"assistant", "content":"x".repeat(context_archive::MAX_INPUT_BYTES + 1)}),
283        )];
284        let err = binding
285            .history_seal("budget-session", &too_big, "")
286            .await
287            .unwrap_err();
288        assert!(err.to_string().contains("input byte budget exceeded"));
289        assert_eq!(binding.history_search("", 8).await.unwrap(), before);
290        binding.history_forget(&receipt.id).await.unwrap();
291        assert!(binding
292            .history_seal("budget-session", &messages, "bounded note")
293            .await
294            .is_err());
295    }
296
297    #[tokio::test]
298    async fn legacy_history_eligible_budget_roundtrip_and_atomic_refusal() {
299        let temp = tempfile::tempdir().unwrap();
300        roundtrip(&binding(temp.path(), MemoryBackendKind::Legacy, None)).await;
301    }
302
303    #[tokio::test]
304    async fn axel_eligible_overflow_refuses_before_service_or_storage() {
305        let temp = tempfile::tempdir().unwrap();
306        let b = binding(
307            temp.path(),
308            MemoryBackendKind::Axel,
309            Some(temp.path().join("never-started")),
310        );
311        let too_big = vec![Arc::new(
312            json!({"role":"user", "content":"x".repeat(context_archive::MAX_INPUT_BYTES + 1)}),
313        )];
314        let err = b
315            .history_seal("budget-session", &too_big, "")
316            .await
317            .unwrap_err();
318        assert!(err.to_string().contains("input byte budget exceeded"));
319        assert_eq!(std::fs::read_dir(temp.path()).unwrap().count(), 0);
320    }
321
322    #[tokio::test]
323    #[ignore = "requires SYNAPS_AXEL_TEST_BIN; isolated synthetic archive only"]
324    async fn real_axel_history_eligible_budget_roundtrip_and_atomic_refusal() {
325        let temp = tempfile::tempdir().unwrap();
326        use std::os::unix::fs::PermissionsExt;
327        std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
328        let exe =
329            std::env::var_os("SYNAPS_AXEL_TEST_BIN").expect("explicit synthetic service binary");
330        roundtrip(&binding(
331            temp.path(),
332            MemoryBackendKind::Axel,
333            Some(exe.into()),
334        ))
335        .await;
336        assert!(!temp.path().join("context-archives").exists());
337    }
338
339    /// Parallel history tools and rollovers share one archive directory per
340    /// project; in-process overlap must queue, never surface "archive busy".
341    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
342    async fn concurrent_legacy_history_operations_queue_instead_of_reporting_busy() {
343        let temp = tempfile::tempdir().unwrap();
344        let binding = binding(temp.path(), MemoryBackendKind::Legacy, None);
345        let tasks: Vec<_> = (0..8)
346            .map(|worker| {
347                let binding = binding.clone();
348                tokio::spawn(async move {
349                    for round in 0..6 {
350                        let text = format!("worker {worker} round {round}");
351                        let messages = vec![Arc::new(json!({"role":"user","content":text}))];
352                        let logical = format!("synthetic-session-{worker}");
353                        let (reference, note) =
354                            binding.history_seal(&logical, &messages, "note").await?;
355                        assert_eq!(note, "note");
356                        assert_eq!(binding.history_fetch(&reference.id, 0, 1).await?.len(), 1);
357                        binding.history_search("worker", 4).await?;
358                    }
359                    Ok::<_, crate::RuntimeError>(())
360                })
361            })
362            .collect();
363        for task in tasks {
364            task.await.unwrap().unwrap();
365        }
366        assert_eq!(binding.history_search("", 32).await.unwrap().len(), 32);
367    }
368
369    /// A caller dropped while its operation waits (e.g. a canceled rollover
370    /// queued behind a history read) must not start it later.
371    #[tokio::test]
372    async fn a_dropped_caller_aborts_its_archive_operation_before_it_starts() {
373        let (entered_tx, entered_rx) = std::sync::mpsc::channel();
374        let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
375        let (seen_tx, seen_rx) = std::sync::mpsc::channel();
376        let call = archive_worker(move |abort| {
377            entered_tx.send(()).unwrap();
378            release_rx.recv().unwrap();
379            seen_tx.send(abort.load(Ordering::Acquire)).unwrap();
380            Ok(())
381        });
382        tokio::select! {
383            biased;
384            _ = tokio::task::spawn_blocking(move || entered_rx.recv().unwrap()) => {}
385            _ = call => panic!("worker cannot finish before release"),
386        }
387        // `call` is dropped; the worker is still running and must see it.
388        release_tx.send(()).unwrap();
389        assert!(tokio::task::spawn_blocking(move || seen_rx.recv().unwrap())
390            .await
391            .unwrap());
392
393        let completed = archive_worker(|abort| Ok(abort.load(Ordering::Acquire)))
394            .await
395            .unwrap();
396        assert!(!completed, "an awaited operation is never aborted");
397    }
398}