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::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}
25async 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 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, ¬e)
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 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 #[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 #[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 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}