1use 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}
26fn 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}
38fn no_archive() -> std::io::Error {
41 std::io::Error::new(std::io::ErrorKind::NotFound, context_archive::NOT_FOUND)
42}
43async 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 archive_worker(move |abort| {
91 ArchiveStore::new(&base, scope.key(), &logical)?
92 .with_redactor(redactor())
93 .with_abort(abort)
94 .seal_with_note(&source, ¬e)
95 })
96 .await
97 }
98 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, ¬e)?;
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 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 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 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 #[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 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 #[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 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 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 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 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 #[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 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 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 #[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 #[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 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}