agent_engine/memory_backend/
history.rs1use 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, ¬e)?;
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}