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::Arc;
10
11fn redactor() -> Arc<ArchiveRedactor> {
12    Arc::new(|text| {
13        let mut value = serde_json::from_str(text).unwrap_or_else(|_| Value::String(text.into()));
14        crate::runtime::trace::export::redact_value(&mut value);
15        match value {
16            Value::String(s) => s,
17            other => other.to_string(),
18        }
19    })
20}
21fn valid_id(id: &str) -> bool {
22    id.len() == 32 && id.bytes().all(|b| b.is_ascii_hexdigit())
23}
24impl MemoryBinding {
25    pub async fn history_seal(
26        &self,
27        logical_id: &str,
28        messages: &[SharedMessage],
29        note: &str,
30    ) -> Result<(ArchiveRef, String)> {
31        let scope = self.scope()?.clone();
32        if !self.exclusive() {
33            let base = self.base().to_owned();
34            let logical = logical_id.to_owned();
35            let source = messages.to_vec();
36            let note = note.to_owned();
37            return tokio::task::spawn_blocking(move || {
38                let store =
39                    ArchiveStore::new(&base, scope.key(), &logical)?.with_redactor(redactor());
40                let r = store.seal(&source, &note)?;
41                let stored = store.fetch_note(&r.id)?;
42                Ok::<_, std::io::Error>((r, stored))
43            })
44            .await
45            .map_err(|_| error("history worker failed"))?
46            .map_err(|e| error(e.to_string()));
47        }
48        let projection =
49            context_archive::project_archive(logical_id, messages, note, Some(redactor()))
50                .map_err(|e| error(e.to_string()))?;
51        let count = projection.messages.len();
52        let source_count = projection.source_message_count;
53        let value = self
54            .rpc(
55                "history_seal",
56                serde_json::to_value(projection).map_err(|_| error("history encoding failed"))?,
57            )
58            .await?;
59        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"))?;
60        let stored = value["note"]
61            .as_str()
62            .ok_or_else(|| error("history commit missing note"))?
63            .to_owned();
64        if !valid_id(&r.id)
65            || r.message_count != count
66            || r.source_message_count != source_count
67            || stored.len() > 8192
68        {
69            return Err(error("history commit acknowledgement mismatch"));
70        }
71        Ok((r, stored))
72    }
73    pub async fn history_search(
74        &self,
75        query: &str,
76        limit: usize,
77    ) -> Result<Vec<ArchiveDescriptor>> {
78        if query.len() > 512 || limit > 32 {
79            return Err(error("history search exceeds bounds"));
80        }
81        let scope = self.scope()?.clone();
82        if !self.exclusive() {
83            let base = self.base().to_owned();
84            let query = query.to_owned();
85            return tokio::task::spawn_blocking(move || {
86                ArchiveStore::new(&base, scope.key(), "context-windows-v1")?.search(&query, limit)
87            })
88            .await
89            .map_err(|_| error("history worker failed"))?
90            .map_err(|e| error(e.to_string()));
91        }
92        let rows: Vec<ArchiveDescriptor> = serde_json::from_value(
93            self.rpc("history_search", json!({"query":query,"limit":limit}))
94                .await?,
95        )
96        .map_err(|_| error("invalid history descriptors"))?;
97        if rows.len() > limit
98            || rows.iter().any(|r| {
99                !valid_id(&r.id)
100                    || r.snippet.len() > 256
101                    || r.message_count > 4096
102                    || r.source_message_count > 4096
103            })
104        {
105            return Err(error("history descriptors exceed bounds"));
106        }
107        Ok(rows)
108    }
109    pub async fn history_fetch(
110        &self,
111        id: &str,
112        start: usize,
113        limit: usize,
114    ) -> Result<Vec<ArchivedMessage>> {
115        if !valid_id(id) || limit > 128 {
116            return Err(error("invalid history range"));
117        }
118        let scope = self.scope()?.clone();
119        if !self.exclusive() {
120            let base = self.base().to_owned();
121            let id = id.to_owned();
122            return tokio::task::spawn_blocking(move || {
123                ArchiveStore::new(&base, scope.key(), "context-windows-v1")?
124                    .fetch_with_provenance(&id, start, limit)
125            })
126            .await
127            .map_err(|_| error("history worker failed"))?
128            .map_err(|e| error(e.to_string()));
129        }
130        let rows: Vec<ArchivedMessage> = serde_json::from_value(
131            self.rpc(
132                "history_fetch",
133                json!({"id":id,"start":start,"limit":limit}),
134            )
135            .await?,
136        )
137        .map_err(|_| error("invalid history fetch"))?;
138        if rows.len() > limit || rows.iter().any(|r| r.source_index >= 4096) {
139            return Err(error("history range exceeds bounds"));
140        }
141        Ok(rows)
142    }
143    pub async fn history_note(&self, id: &str) -> Result<String> {
144        if !valid_id(id) {
145            return Err(error("invalid history id"));
146        }
147        let scope = self.scope()?.clone();
148        if !self.exclusive() {
149            let base = self.base().to_owned();
150            let id = id.to_owned();
151            return tokio::task::spawn_blocking(move || {
152                ArchiveStore::new(&base, scope.key(), "context-windows-v1")?.fetch_note(&id)
153            })
154            .await
155            .map_err(|_| error("history worker failed"))?
156            .map_err(|e| error(e.to_string()));
157        }
158        let note: String =
159            serde_json::from_value(self.rpc("history_note", json!({"id":id})).await?)
160                .map_err(|_| error("invalid history note"))?;
161        if note.len() > 8192 {
162            return Err(error("history note exceeds bounds"));
163        }
164        Ok(note)
165    }
166    pub async fn history_forget(&self, id: &str) -> Result<()> {
167        if !valid_id(id) {
168            return Err(error("invalid history id"));
169        }
170        let scope = self.scope()?.clone();
171        if !self.exclusive() {
172            let base = self.base().to_owned();
173            let id = id.to_owned();
174            return tokio::task::spawn_blocking(move || {
175                ArchiveStore::new(&base, scope.key(), "context-windows-v1")?.forget(&id)
176            })
177            .await
178            .map_err(|_| error("history worker failed"))?
179            .map_err(|e| error(e.to_string()));
180        }
181        if self.rpc("history_forget", json!({"id":id})).await? != true {
182            return Err(error("history not found in project"));
183        }
184        Ok(())
185    }
186}
187
188#[cfg(test)]
189mod budget_tests {
190    use super::*;
191    use agent_core::config::{MemoryBackendConfig, MemoryBackendKind};
192    use agent_core::memory::store::ProjectScope;
193    use std::path::Path;
194
195    fn binding(
196        root: &Path,
197        kind: MemoryBackendKind,
198        exe: Option<std::path::PathBuf>,
199    ) -> MemoryBinding {
200        MemoryBinding::new(
201            root.into(),
202            Ok(ProjectScope::for_root(root).unwrap()),
203            &MemoryBackendConfig {
204                kind,
205                executable: exe,
206                brain: Some(root.join("brain.r8")),
207                user_scope: false,
208            },
209        )
210    }
211
212    async fn roundtrip(binding: &MemoryBinding) {
213        let messages = vec![
214            Arc::new(json!({"role":"system","content":"x".repeat(9 * 1024 * 1024)})),
215            Arc::new(
216                json!({"role":"assistant","metadata":"x".repeat(9 * 1024 * 1024),"content":[
217                    {"type":"text","text":"exact source αβ\n"},
218                    {"type":"image","source":{"data":"x".repeat(1024 * 1024)}}
219                ]}),
220            ),
221            Arc::new(json!({"role":"user","content":[{
222                "type":"tool_result","tool_use_id":"r1","content":"exact tool log\n"
223            }]})),
224        ];
225        let original = messages.clone();
226        let (receipt, note) = binding
227            .history_seal("budget-session", &messages, "bounded note")
228            .await
229            .unwrap();
230        assert_eq!(receipt.source_message_count, 3);
231        assert_eq!(receipt.message_count, 2);
232        assert_eq!(note, "bounded note");
233        let fetched = binding.history_fetch(&receipt.id, 0, 8).await.unwrap();
234        assert_eq!(fetched[0].source_index, 1);
235        assert_eq!(fetched[0].block_indices, vec![0]);
236        assert_eq!(
237            fetched[0].message["content"][0]["text"],
238            "exact source αβ\n"
239        );
240        assert_eq!(fetched[1].message, messages[2]);
241        assert_eq!(messages, original);
242        let (retry, _) = binding
243            .history_seal("budget-session", &messages, "different note")
244            .await
245            .unwrap();
246        assert_eq!(retry.id, receipt.id);
247        let before = binding.history_search("", 8).await.unwrap();
248        let too_big = vec![Arc::new(
249            json!({"role":"assistant", "content":"x".repeat(context_archive::MAX_INPUT_BYTES + 1)}),
250        )];
251        let err = binding
252            .history_seal("budget-session", &too_big, "")
253            .await
254            .unwrap_err();
255        assert!(err.to_string().contains("input byte budget exceeded"));
256        assert_eq!(binding.history_search("", 8).await.unwrap(), before);
257        binding.history_forget(&receipt.id).await.unwrap();
258        assert!(binding
259            .history_seal("budget-session", &messages, "bounded note")
260            .await
261            .is_err());
262    }
263
264    #[tokio::test]
265    async fn legacy_history_eligible_budget_roundtrip_and_atomic_refusal() {
266        let temp = tempfile::tempdir().unwrap();
267        roundtrip(&binding(temp.path(), MemoryBackendKind::Legacy, None)).await;
268    }
269
270    #[tokio::test]
271    async fn axel_eligible_overflow_refuses_before_service_or_storage() {
272        let temp = tempfile::tempdir().unwrap();
273        let b = binding(
274            temp.path(),
275            MemoryBackendKind::Axel,
276            Some(temp.path().join("never-started")),
277        );
278        let too_big = vec![Arc::new(
279            json!({"role":"user", "content":"x".repeat(context_archive::MAX_INPUT_BYTES + 1)}),
280        )];
281        let err = b
282            .history_seal("budget-session", &too_big, "")
283            .await
284            .unwrap_err();
285        assert!(err.to_string().contains("input byte budget exceeded"));
286        assert_eq!(std::fs::read_dir(temp.path()).unwrap().count(), 0);
287    }
288
289    #[tokio::test]
290    #[ignore = "requires SYNAPS_AXEL_TEST_BIN; isolated synthetic archive only"]
291    async fn real_axel_history_eligible_budget_roundtrip_and_atomic_refusal() {
292        let temp = tempfile::tempdir().unwrap();
293        use std::os::unix::fs::PermissionsExt;
294        std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
295        let exe =
296            std::env::var_os("SYNAPS_AXEL_TEST_BIN").expect("explicit synthetic service binary");
297        roundtrip(&binding(
298            temp.path(),
299            MemoryBackendKind::Axel,
300            Some(exe.into()),
301        ))
302        .await;
303        assert!(!temp.path().join("context-archives").exists());
304    }
305}