1use super::chat_capture::{
4 build_chat_turn_capture, build_conversation_summary_capture, CaptureBuildError,
5 ChatTurnCapture, ConversationSummaryCapture, SummaryCaptureBuildError, TerminalTurnHistory,
6};
7use super::memory_context::{ContextProviderId, MemoryContextLease, RetentionClass, TurnCapture};
8use std::collections::HashSet;
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::sync::{mpsc, Arc};
11use std::thread;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum CaptureCommitState {
16 Committed,
17 Absent,
18}
19
20pub trait CaptureProvider: Send + Sync + 'static {
24 fn capture(&self, capture: ChatTurnCapture) -> Result<(), CaptureFailure>;
25
26 fn contains_capture(
30 &self,
31 _capture_id: &[u8; 32],
32 ) -> Result<CaptureCommitState, CaptureFailure> {
33 Ok(CaptureCommitState::Absent)
34 }
35
36 fn capture_summary(&self, _capture: ConversationSummaryCapture) -> Result<(), CaptureFailure> {
37 Err(CaptureFailure {
38 code: "summary_capture_unsupported",
39 })
40 }
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct CaptureFailure {
46 pub code: &'static str,
47}
48
49enum CapturePayload {
50 Turn(Box<ChatTurnCapture>),
51 Summary(Box<ConversationSummaryCapture>),
52}
53
54impl CapturePayload {
55 fn id_bytes(&self) -> [u8; 32] {
56 match self {
57 Self::Turn(capture) => *capture.capture_id.as_bytes(),
58 Self::Summary(capture) => *capture.capture_id.as_bytes(),
59 }
60 }
61
62 fn dispatch(&self, provider: &dyn CaptureProvider) -> Result<(), CaptureFailure> {
63 match self {
64 Self::Turn(capture) => provider.capture(capture.as_ref().clone()),
65 Self::Summary(capture) => provider.capture_summary(capture.as_ref().clone()),
66 }
67 }
68
69 fn query_committed(
70 &self,
71 provider: &dyn CaptureProvider,
72 ) -> Result<CaptureCommitState, CaptureFailure> {
73 match self {
74 Self::Turn(capture) => provider.contains_capture(capture.capture_id.as_bytes()),
75 Self::Summary(_) => Ok(CaptureCommitState::Absent),
78 }
79 }
80}
81
82struct CaptureJob {
83 provider_id: ContextProviderId,
84 provider: Arc<dyn CaptureProvider>,
85 payload: CapturePayload,
86 committed: Option<mpsc::Sender<Result<(), CaptureFailure>>>,
89}
90
91pub struct CaptureWorker {
94 sender: mpsc::SyncSender<CaptureJob>,
95 dropped: Arc<AtomicU64>,
96 failures: Arc<AtomicU64>,
97}
98
99impl CaptureWorker {
100 pub fn new(capacity: usize) -> Self {
101 assert!(capacity > 0, "capture queue capacity must be nonzero");
102 let (sender, receiver) = mpsc::sync_channel::<CaptureJob>(capacity);
103 let dropped = Arc::new(AtomicU64::new(0));
104 let failures = Arc::new(AtomicU64::new(0));
105 let worker_failures = Arc::clone(&failures);
106 thread::Builder::new()
107 .name("memory-capture".into())
108 .spawn(move || {
109 let mut submitted = HashSet::new();
110 while let Ok(job) = receiver.recv() {
111 let id = (job.provider_id.clone(), job.payload.id_bytes());
112 let result = if submitted.contains(&id) {
113 Ok(())
114 } else if let Err(error) = job.payload.dispatch(job.provider.as_ref()) {
115 match job.payload.query_committed(job.provider.as_ref()) {
119 Ok(CaptureCommitState::Committed) => {
120 submitted.insert(id);
121 Ok(())
122 }
123 Ok(CaptureCommitState::Absent) | Err(_) => {
124 worker_failures.fetch_add(1, Ordering::Relaxed);
125 Err(error)
126 }
127 }
128 } else {
129 submitted.insert(id);
130 Ok(())
131 };
132 if let Some(committed) = job.committed {
133 let _ = committed.send(result);
134 }
135 }
136 })
137 .expect("capture worker spawn");
138 Self {
139 sender,
140 dropped,
141 failures,
142 }
143 }
144
145 pub fn submit_terminal(
148 &self,
149 lease: &MemoryContextLease,
150 history: TerminalTurnHistory,
151 retention: RetentionClass,
152 provider: Arc<dyn CaptureProvider>,
153 ) -> Result<bool, CaptureBuildError> {
154 if lease.mode.turn_capture() != TurnCapture::Enabled
155 || lease.project_id != history.project_id
156 || lease.session_id != history.session_id
157 {
158 return Ok(false);
159 }
160 let capture = build_chat_turn_capture(&lease.project_id, history, retention)?;
161 let job = CaptureJob {
162 provider_id: lease.provider_id.clone(),
163 provider,
164 payload: CapturePayload::Turn(Box::new(capture)),
165 committed: None,
166 };
167 match self.sender.try_send(job) {
168 Ok(()) => Ok(true),
169 Err(mpsc::TrySendError::Full(_)) | Err(mpsc::TrySendError::Disconnected(_)) => {
170 self.dropped.fetch_add(1, Ordering::Relaxed);
171 Ok(true)
172 }
173 }
174 }
175
176 pub(crate) fn submit_built(
180 &self,
181 lease: &MemoryContextLease,
182 capture: ChatTurnCapture,
183 provider: Arc<dyn CaptureProvider>,
184 ) -> bool {
185 self.submit_built_job(lease, capture, provider, None)
186 }
187
188 pub(crate) fn submit_built_with_receipt(
191 &self,
192 lease: &MemoryContextLease,
193 capture: ChatTurnCapture,
194 provider: Arc<dyn CaptureProvider>,
195 committed: mpsc::Sender<Result<(), CaptureFailure>>,
196 ) -> bool {
197 self.submit_built_job(lease, capture, provider, Some(committed))
198 }
199
200 fn submit_built_job(
201 &self,
202 lease: &MemoryContextLease,
203 capture: ChatTurnCapture,
204 provider: Arc<dyn CaptureProvider>,
205 committed: Option<mpsc::Sender<Result<(), CaptureFailure>>>,
206 ) -> bool {
207 if lease.mode.turn_capture() != TurnCapture::Enabled
208 || lease.project_id != capture.project_id
209 {
210 return false;
211 }
212 let job = CaptureJob {
213 provider_id: lease.provider_id.clone(),
214 provider,
215 payload: CapturePayload::Turn(Box::new(capture)),
216 committed,
217 };
218 match self.sender.try_send(job) {
219 Ok(()) => true,
220 Err(mpsc::TrySendError::Full(_)) | Err(mpsc::TrySendError::Disconnected(_)) => {
221 self.dropped.fetch_add(1, Ordering::Relaxed);
222 false
223 }
224 }
225 }
226
227 #[allow(clippy::too_many_arguments)] pub fn submit_summary(
231 &self,
232 lease: &MemoryContextLease,
233 source: super::chat_capture::CompactionSource,
234 source_message_count: usize,
235 summary_text: &str,
236 redaction_policy: agent_core::compaction::RedactionPolicy,
237 retention: RetentionClass,
238 provider: Arc<dyn CaptureProvider>,
239 ) -> Result<bool, SummaryCaptureBuildError> {
240 if lease.mode.turn_capture() != TurnCapture::Enabled
241 || lease.project_id != source.project_id
242 {
243 return Ok(false);
244 }
245 let capture = build_conversation_summary_capture(
246 &lease.project_id,
247 source,
248 source_message_count,
249 summary_text,
250 redaction_policy,
251 retention,
252 )?;
253 let job = CaptureJob {
254 provider_id: lease.provider_id.clone(),
255 provider,
256 payload: CapturePayload::Summary(Box::new(capture)),
257 committed: None,
258 };
259 match self.sender.try_send(job) {
260 Ok(()) => Ok(true),
261 Err(mpsc::TrySendError::Full(_)) | Err(mpsc::TrySendError::Disconnected(_)) => {
262 self.dropped.fetch_add(1, Ordering::Relaxed);
263 Ok(true)
264 }
265 }
266 }
267
268 pub fn overflow_drops(&self) -> u64 {
269 self.dropped.load(Ordering::Relaxed)
270 }
271 pub fn provider_failures(&self) -> u64 {
272 self.failures.load(Ordering::Relaxed)
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279 use crate::runtime::chat_capture::{CanonicalCaptureItem, CaptureContentClass};
280 use crate::runtime::memory_context::{
281 mint_explicit_command_proof, CapturePolicy, MemoryContextMode, MemoryLeaseId, ProjectId,
282 RecallPolicy, SessionId,
283 };
284 use agent_core::core::disclosure::DisclosureClass;
285 use agent_core::TurnOutcome;
286 use std::sync::atomic::AtomicBool;
287 use std::sync::Mutex;
288 use std::time::{Duration, Instant, SystemTime};
289
290 struct ProviderDouble {
291 calls: AtomicU64,
292 queries: AtomicU64,
293 fail: AtomicBool,
294 committed: AtomicBool,
295 block: Mutex<Option<mpsc::Receiver<()>>>,
296 }
297
298 impl CaptureProvider for ProviderDouble {
299 fn capture(&self, _capture: ChatTurnCapture) -> Result<(), CaptureFailure> {
300 self.calls.fetch_add(1, Ordering::SeqCst);
301 if let Some(receiver) = self.block.lock().expect("lock").take() {
302 let _ = receiver.recv();
303 }
304 if self.fail.load(Ordering::SeqCst) {
305 Err(CaptureFailure {
306 code: "fixture_failure",
307 })
308 } else {
309 self.committed.store(true, Ordering::SeqCst);
310 Ok(())
311 }
312 }
313
314 fn contains_capture(
315 &self,
316 _capture_id: &[u8; 32],
317 ) -> Result<CaptureCommitState, CaptureFailure> {
318 self.queries.fetch_add(1, Ordering::SeqCst);
319 Ok(if self.committed.load(Ordering::SeqCst) {
320 CaptureCommitState::Committed
321 } else {
322 CaptureCommitState::Absent
323 })
324 }
325 }
326
327 fn lease() -> MemoryContextLease {
328 let now = SystemTime::now();
329 MemoryContextLease::grant(
330 MemoryLeaseId::parse("lease-c3").unwrap(),
331 SessionId::parse("session-c3").unwrap(),
332 ProjectId::parse("project-c3").unwrap(),
333 ContextProviderId::parse("provider-c3").unwrap(),
334 MemoryContextMode::CaptureOnly,
335 CapturePolicy::EligibleTurnsOnly,
336 RecallPolicy::BoundedPerPrompt,
337 mint_explicit_command_proof(),
338 now,
339 None,
340 )
341 .unwrap()
342 }
343
344 fn history(turn: u64) -> TerminalTurnHistory {
345 let project_id = ProjectId::parse("project-c3").unwrap();
346 let started_at = SystemTime::UNIX_EPOCH + Duration::from_secs(turn);
347 TerminalTurnHistory {
348 project_id: project_id.clone(),
349 session_id: SessionId::parse("session-c3").unwrap(),
350 turn_id: super::super::memory_context::TurnId::parse(&format!("turn-{turn}")).unwrap(),
351 turn_ordinal: turn,
352 started_at,
353 completed_at: started_at + Duration::from_millis(1),
354 outcome: TurnOutcome::Completed,
355 items: vec![
356 CanonicalCaptureItem {
357 project_id: project_id.clone(),
358 class: CaptureContentClass::UserMessage,
359 disclosure: DisclosureClass::ModelVisible,
360 sensitivity: super::super::chat_capture::Sensitivity::Normal,
361 text: "user".into(),
362 tool_name: None,
363 },
364 CanonicalCaptureItem {
365 project_id,
366 class: CaptureContentClass::AssistantFinal,
367 disclosure: DisclosureClass::ModelVisible,
368 sensitivity: super::super::chat_capture::Sensitivity::Normal,
369 text: "assistant".into(),
370 tool_name: None,
371 },
372 ],
373 compaction: None,
374 }
375 }
376
377 fn wait_until(predicate: impl Fn() -> bool) {
378 let deadline = Instant::now() + Duration::from_secs(1);
379 while !predicate() && Instant::now() < deadline {
380 thread::sleep(Duration::from_millis(2));
381 }
382 assert!(predicate());
383 }
384
385 #[test]
386 fn memory_duplicate_capture_id_idempotent_at_engine_seam() {
387 let provider = Arc::new(ProviderDouble {
388 calls: AtomicU64::new(0),
389 queries: AtomicU64::new(0),
390 fail: AtomicBool::new(false),
391 committed: AtomicBool::new(false),
392 block: Mutex::new(None),
393 });
394 let worker = CaptureWorker::new(4);
395 worker
396 .submit_terminal(
397 &lease(),
398 history(1),
399 RetentionClass::Standard,
400 provider.clone(),
401 )
402 .unwrap();
403 worker
404 .submit_terminal(
405 &lease(),
406 history(1),
407 RetentionClass::Standard,
408 provider.clone(),
409 )
410 .unwrap();
411 wait_until(|| provider.calls.load(Ordering::SeqCst) == 1);
412 thread::sleep(Duration::from_millis(10));
413 assert_eq!(provider.calls.load(Ordering::SeqCst), 1);
414 }
415
416 #[test]
417 fn memory_overflow_drops_exactly_and_never_blocks_completion() {
418 let (release_tx, release_rx) = mpsc::channel();
419 let provider = Arc::new(ProviderDouble {
420 calls: AtomicU64::new(0),
421 queries: AtomicU64::new(0),
422 fail: AtomicBool::new(false),
423 committed: AtomicBool::new(false),
424 block: Mutex::new(Some(release_rx)),
425 });
426 let worker = CaptureWorker::new(1);
427 worker
428 .submit_terminal(
429 &lease(),
430 history(1),
431 RetentionClass::Standard,
432 provider.clone(),
433 )
434 .unwrap();
435 wait_until(|| provider.calls.load(Ordering::SeqCst) == 1);
436 worker
437 .submit_terminal(
438 &lease(),
439 history(2),
440 RetentionClass::Standard,
441 provider.clone(),
442 )
443 .unwrap();
444 let started = Instant::now();
445 for turn in 3..103 {
446 worker
447 .submit_terminal(
448 &lease(),
449 history(turn),
450 RetentionClass::Standard,
451 provider.clone(),
452 )
453 .unwrap();
454 }
455 assert!(started.elapsed() < Duration::from_millis(50));
456 assert_eq!(worker.overflow_drops(), 100);
457 release_tx.send(()).unwrap();
458 }
459
460 #[test]
461 fn memory_possibly_committed_capture_is_queried_before_retry() {
462 struct AckLostAfterCommit {
463 calls: AtomicU64,
464 queries: AtomicU64,
465 committed: AtomicBool,
466 }
467 impl CaptureProvider for AckLostAfterCommit {
468 fn capture(&self, _capture: ChatTurnCapture) -> Result<(), CaptureFailure> {
469 self.calls.fetch_add(1, Ordering::SeqCst);
470 self.committed.store(true, Ordering::SeqCst);
471 Err(CaptureFailure { code: "ack_lost" })
472 }
473
474 fn contains_capture(
475 &self,
476 _capture_id: &[u8; 32],
477 ) -> Result<CaptureCommitState, CaptureFailure> {
478 self.queries.fetch_add(1, Ordering::SeqCst);
479 Ok(if self.committed.load(Ordering::SeqCst) {
480 CaptureCommitState::Committed
481 } else {
482 CaptureCommitState::Absent
483 })
484 }
485 }
486
487 let provider = Arc::new(AckLostAfterCommit {
488 calls: AtomicU64::new(0),
489 queries: AtomicU64::new(0),
490 committed: AtomicBool::new(false),
491 });
492 let worker = CaptureWorker::new(2);
493 for _ in 0..2 {
494 worker
495 .submit_terminal(
496 &lease(),
497 history(99),
498 RetentionClass::Standard,
499 provider.clone(),
500 )
501 .unwrap();
502 wait_until(|| provider.queries.load(Ordering::SeqCst) == 1);
503 }
504 thread::sleep(Duration::from_millis(10));
505 assert_eq!(
506 provider.calls.load(Ordering::SeqCst),
507 1,
508 "must not blindly retry"
509 );
510 assert_eq!(provider.queries.load(Ordering::SeqCst), 1);
511 assert_eq!(worker.provider_failures(), 0, "query proved the commit");
512 }
513
514 #[test]
515 fn memory_failed_capture_id_can_be_explicitly_retried() {
516 let provider = Arc::new(ProviderDouble {
517 calls: AtomicU64::new(0),
518 queries: AtomicU64::new(0),
519 fail: AtomicBool::new(true),
520 committed: AtomicBool::new(false),
521 block: Mutex::new(None),
522 });
523 let worker = CaptureWorker::new(2);
524 worker
525 .submit_terminal(
526 &lease(),
527 history(7),
528 RetentionClass::Standard,
529 provider.clone(),
530 )
531 .unwrap();
532 wait_until(|| worker.provider_failures() == 1);
533 provider.fail.store(false, Ordering::SeqCst);
534 worker
535 .submit_terminal(
536 &lease(),
537 history(7),
538 RetentionClass::Standard,
539 provider.clone(),
540 )
541 .unwrap();
542 wait_until(|| provider.calls.load(Ordering::SeqCst) == 2);
543 }
544
545 #[test]
546 fn memory_provider_capture_failure_leaves_typed_terminal_turn_completed() {
547 let provider = Arc::new(ProviderDouble {
548 calls: AtomicU64::new(0),
549 queries: AtomicU64::new(0),
550 fail: AtomicBool::new(true),
551 committed: AtomicBool::new(false),
552 block: Mutex::new(None),
553 });
554 let worker = CaptureWorker::new(1);
555 let terminal = history(1);
556 assert_eq!(terminal.outcome, TurnOutcome::Completed);
557 assert!(worker
558 .submit_terminal(
559 &lease(),
560 terminal,
561 RetentionClass::Standard,
562 provider.clone()
563 )
564 .unwrap());
565 wait_until(|| worker.provider_failures() == 1);
566 }
567}