1#![cfg_attr(
2 test,
3 allow(
4 clippy::expect_used,
5 clippy::indexing_slicing,
6 clippy::panic,
7 clippy::unwrap_used,
8 clippy::unreachable
9 )
10)]
11use std::{
43 borrow::Cow,
44 collections::HashMap,
45 sync::{Arc, Mutex as StdMutex},
46};
47
48pub use rig_core::memory::{
51 Compactor, ConversationMemory, DemotionHook, InMemoryConversationMemory, MemoryError,
52 NoopDemotionHook,
53};
54
55use rig_core::completion::Message;
56use rig_core::message::UserContent;
57use rig_core::wasm_compat::{WasmBoxedFuture, WasmCompatSend, WasmCompatSync};
58
59pub trait MemoryPolicy: WasmCompatSend + WasmCompatSync {
65 fn apply(&self, messages: Vec<Message>) -> Result<Vec<Message>, MemoryError>;
68
69 fn apply_with_demoted(
83 &self,
84 messages: Vec<Message>,
85 ) -> Result<(Vec<Message>, Vec<Message>), MemoryError> {
86 Ok((self.apply(messages)?, Vec::new()))
87 }
88}
89
90impl<P> MemoryPolicy for Arc<P>
91where
92 P: MemoryPolicy + ?Sized,
93{
94 fn apply(&self, messages: Vec<Message>) -> Result<Vec<Message>, MemoryError> {
95 (**self).apply(messages)
96 }
97
98 fn apply_with_demoted(
99 &self,
100 messages: Vec<Message>,
101 ) -> Result<(Vec<Message>, Vec<Message>), MemoryError> {
102 (**self).apply_with_demoted(messages)
103 }
104}
105
106impl<P> MemoryPolicy for Box<P>
107where
108 P: MemoryPolicy + ?Sized,
109{
110 fn apply(&self, messages: Vec<Message>) -> Result<Vec<Message>, MemoryError> {
111 (**self).apply(messages)
112 }
113
114 fn apply_with_demoted(
115 &self,
116 messages: Vec<Message>,
117 ) -> Result<(Vec<Message>, Vec<Message>), MemoryError> {
118 (**self).apply_with_demoted(messages)
119 }
120}
121
122pub trait IntoFilter: MemoryPolicy + Sized + 'static {
129 fn into_filter(self) -> BoxedFilter {
136 let policy = Arc::new(self);
137 Box::new(move |msgs| {
138 let fallback = msgs.clone();
139 match policy.apply(msgs) {
140 Ok(out) => out,
141 Err(err) => {
142 tracing::warn!(error = %err, "memory policy failed; returning unfiltered history");
143 fallback
144 }
145 }
146 })
147 }
148}
149
150#[cfg(not(target_family = "wasm"))]
152pub type BoxedFilter = Box<dyn Fn(Vec<Message>) -> Vec<Message> + Send + Sync>;
153
154#[cfg(target_family = "wasm")]
156pub type BoxedFilter = Box<dyn Fn(Vec<Message>) -> Vec<Message>>;
157
158impl<P> IntoFilter for P where P: MemoryPolicy + 'static {}
159
160#[derive(Debug, Default, Clone, Copy)]
162pub struct NoopMemoryPolicy;
163
164impl MemoryPolicy for NoopMemoryPolicy {
165 fn apply(&self, messages: Vec<Message>) -> Result<Vec<Message>, MemoryError> {
166 Ok(messages)
167 }
168}
169
170#[derive(Debug, Clone, Copy)]
177pub struct SlidingWindowMemory {
178 max_messages: usize,
179}
180
181impl SlidingWindowMemory {
182 pub fn last_messages(n: usize) -> Self {
184 Self { max_messages: n }
185 }
186}
187
188impl MemoryPolicy for SlidingWindowMemory {
189 fn apply(&self, messages: Vec<Message>) -> Result<Vec<Message>, MemoryError> {
190 Ok(self.apply_with_demoted(messages)?.0)
191 }
192
193 fn apply_with_demoted(
194 &self,
195 messages: Vec<Message>,
196 ) -> Result<(Vec<Message>, Vec<Message>), MemoryError> {
197 if messages.len() <= self.max_messages {
198 return Ok((messages, Vec::new()));
199 }
200
201 let keep_from = messages.len() - self.max_messages;
202 Ok(split_window(messages, keep_from))
203 }
204}
205
206fn split_window(messages: Vec<Message>, keep_from: usize) -> (Vec<Message>, Vec<Message>) {
213 let mut iter = messages.into_iter();
214 let mut demoted: Vec<Message> = (&mut iter).take(keep_from).collect();
215 let mut window: Vec<Message> = iter.collect();
216
217 if let Some(Message::User { content }) = window.first()
218 && matches!(content.first(), Some(UserContent::ToolResult(_)))
219 {
220 demoted.push(window.remove(0));
221 }
222
223 (window, demoted)
224}
225
226pub trait TokenCounter: WasmCompatSend + WasmCompatSync {
232 fn count(&self, message: &Message) -> usize;
234}
235
236impl<F> TokenCounter for F
237where
238 F: Fn(&Message) -> usize + WasmCompatSend + WasmCompatSync,
239{
240 fn count(&self, message: &Message) -> usize {
241 (self)(message)
242 }
243}
244
245impl<C> TokenCounter for Arc<C>
246where
247 C: TokenCounter + ?Sized,
248{
249 fn count(&self, message: &Message) -> usize {
250 (**self).count(message)
251 }
252}
253
254impl TokenCounter for Box<dyn TokenCounter> {
255 fn count(&self, message: &Message) -> usize {
256 (**self).count(message)
257 }
258}
259
260#[derive(Debug, Clone, Copy)]
302pub struct HeuristicTokenCounter {
303 bytes_per_token: f32,
304 per_message_overhead: usize,
305 per_attachment_tokens: usize,
306}
307
308impl HeuristicTokenCounter {
309 pub fn new(
314 bytes_per_token: f32,
315 per_message_overhead: usize,
316 per_attachment_tokens: usize,
317 ) -> Self {
318 let bytes_per_token = if bytes_per_token.is_finite() && bytes_per_token >= 1.0 {
319 bytes_per_token
320 } else {
321 1.0
322 };
323 Self {
324 bytes_per_token,
325 per_message_overhead,
326 per_attachment_tokens,
327 }
328 }
329
330 pub fn openai() -> Self {
334 Self::new(4.0, 4, 256)
335 }
336
337 pub fn anthropic() -> Self {
339 Self::new(3.5, 4, 256)
340 }
341
342 pub fn gemini() -> Self {
344 Self::new(4.0, 4, 256)
345 }
346
347 fn bytes_to_tokens(&self, bytes: usize) -> usize {
348 let tokens = (bytes as f32) / self.bytes_per_token;
352 tokens.ceil() as usize
353 }
354
355 fn count_user(&self, content: &rig_core::message::UserContent) -> usize {
356 use rig_core::message::UserContent;
357 match content {
358 UserContent::Text(text) => self.bytes_to_tokens(text.text.len()),
359 UserContent::ToolResult(result) => result
360 .content
361 .iter()
362 .map(|c| match c {
363 rig_core::message::ToolResultContent::Text(t) => {
364 self.bytes_to_tokens(t.text.len())
365 }
366 rig_core::message::ToolResultContent::Json { value } => {
367 self.bytes_to_tokens(value.to_string().len())
368 }
369 rig_core::message::ToolResultContent::Image(_) => self.per_attachment_tokens,
370 })
371 .sum(),
372 UserContent::Image(_)
373 | UserContent::Audio(_)
374 | UserContent::Video(_)
375 | UserContent::Document(_) => self.per_attachment_tokens,
376 }
377 }
378
379 fn count_assistant(&self, content: &rig_core::message::AssistantContent) -> usize {
380 use rig_core::message::AssistantContent;
381 match content {
382 AssistantContent::Text(text) => self.bytes_to_tokens(text.text.len()),
383 AssistantContent::Reasoning(reasoning) => {
384 self.bytes_to_tokens(reasoning.display_text().len())
385 }
386 AssistantContent::ToolCall(call) => {
387 let name_bytes = call.function.name.len();
388 let args_bytes = call.function.arguments.to_string().len();
393 self.bytes_to_tokens(name_bytes + args_bytes)
394 }
395 AssistantContent::Image(_) => self.per_attachment_tokens,
396 }
397 }
398}
399
400impl Default for HeuristicTokenCounter {
401 fn default() -> Self {
402 Self::openai()
403 }
404}
405
406impl TokenCounter for HeuristicTokenCounter {
407 fn count(&self, message: &Message) -> usize {
408 let content_tokens: usize = match message {
409 Message::User { content } => content.iter().map(|c| self.count_user(c)).sum(),
410 Message::Assistant { content, .. } => {
411 content.iter().map(|c| self.count_assistant(c)).sum()
412 }
413 Message::System { content } => self.bytes_to_tokens(content.len()),
414 };
415 content_tokens.saturating_add(self.per_message_overhead)
416 }
417}
418
419pub struct TokenWindowMemory {
428 max_tokens: usize,
429 counter: Arc<dyn TokenCounter>,
430}
431
432impl TokenWindowMemory {
433 pub fn new<C>(max_tokens: usize, counter: C) -> Self
435 where
436 C: TokenCounter + 'static,
437 {
438 Self {
439 max_tokens,
440 counter: Arc::new(counter),
441 }
442 }
443}
444
445impl std::fmt::Debug for TokenWindowMemory {
446 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
447 f.debug_struct("TokenWindowMemory")
448 .field("max_tokens", &self.max_tokens)
449 .field("counter", &"<counter>")
450 .finish()
451 }
452}
453
454impl MemoryPolicy for TokenWindowMemory {
455 fn apply(&self, messages: Vec<Message>) -> Result<Vec<Message>, MemoryError> {
456 Ok(self.apply_with_demoted(messages)?.0)
457 }
458
459 fn apply_with_demoted(
460 &self,
461 messages: Vec<Message>,
462 ) -> Result<(Vec<Message>, Vec<Message>), MemoryError> {
463 let mut budget = self.max_tokens;
464 let mut keep_from = messages.len();
465
466 for (idx, msg) in messages.iter().enumerate().rev() {
467 let cost = self.counter.count(msg);
468 if cost > budget {
469 break;
470 }
471 budget -= cost;
472 keep_from = idx;
473 }
474
475 Ok(split_window(messages, keep_from))
476 }
477}
478
479#[derive(Debug, Clone, Copy)]
498pub struct PolicyMemory<M, P> {
499 inner: M,
500 policy: P,
501}
502
503impl<M, P> PolicyMemory<M, P> {
504 pub fn new(inner: M, policy: P) -> Self {
506 Self { inner, policy }
507 }
508
509 pub fn inner(&self) -> &M {
511 &self.inner
512 }
513
514 pub fn policy(&self) -> &P {
516 &self.policy
517 }
518
519 pub fn into_inner(self) -> (M, P) {
521 (self.inner, self.policy)
522 }
523}
524
525impl<M, P> ConversationMemory for PolicyMemory<M, P>
526where
527 M: ConversationMemory,
528 P: MemoryPolicy,
529{
530 fn load<'a>(
531 &'a self,
532 conversation_id: &'a str,
533 ) -> WasmBoxedFuture<'a, Result<Vec<Message>, MemoryError>> {
534 Box::pin(async move {
535 let messages = self.inner.load(conversation_id).await?;
536 self.policy.apply(messages)
537 })
538 }
539
540 fn append<'a>(
541 &'a self,
542 conversation_id: &'a str,
543 messages: Vec<Message>,
544 ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
545 self.inner.append(conversation_id, messages)
546 }
547
548 fn clear<'a>(
549 &'a self,
550 conversation_id: &'a str,
551 ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
552 self.inner.clear(conversation_id)
553 }
554}
555
556pub struct DemotingPolicyMemory<M, P, H> {
608 inner: M,
609 policy: P,
610 hook: H,
611 state: StdMutex<HashMap<String, ConversationDemotionState>>,
612}
613
614type InFlightReservation = Arc<()>;
615
616macro_rules! stateful_wrapper_common {
621 ($ty:ident, $third:ident: $tgen:ident $(: $tbound:ident)?) => {
622 impl<M, P, $tgen $(: $tbound)?> $ty<M, P, $tgen> {
623 pub fn inner(&self) -> &M {
625 &self.inner
626 }
627
628 pub fn policy(&self) -> &P {
630 &self.policy
631 }
632
633 pub fn $third(&self) -> &$tgen {
635 &self.$third
636 }
637
638 pub fn into_inner(self) -> (M, P, $tgen) {
640 (self.inner, self.policy, self.$third)
641 }
642
643 pub fn forget(&self, conversation_id: &str) {
652 if let Ok(mut guard) = self.state.lock() {
653 guard.remove(conversation_id);
654 }
655 }
656
657 pub fn tracked_conversations(&self) -> usize {
661 self.state.lock().map(|g| g.len()).unwrap_or(0)
662 }
663 }
664
665 impl<M, P, $tgen $(: $tbound)?> std::fmt::Debug for $ty<M, P, $tgen>
666 where
667 M: std::fmt::Debug,
668 P: std::fmt::Debug,
669 {
670 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
671 f.debug_struct(stringify!($ty))
672 .field("inner", &self.inner)
673 .field("policy", &self.policy)
674 .field(
675 stringify!($third),
676 &concat!("<", stringify!($third), ">"),
677 )
678 .finish()
679 }
680 }
681 };
682}
683
684macro_rules! stateful_wrapper_append_clear {
687 () => {
688 fn append<'a>(
689 &'a self,
690 conversation_id: &'a str,
691 messages: Vec<Message>,
692 ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
693 self.inner.append(conversation_id, messages)
694 }
695
696 fn clear<'a>(
697 &'a self,
698 conversation_id: &'a str,
699 ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
700 Box::pin(async move {
701 self.inner.clear(conversation_id).await?;
702 self.forget(conversation_id);
703 Ok(())
704 })
705 }
706 };
707}
708
709#[derive(Debug, Default, Clone)]
710struct ConversationDemotionState {
711 delivered: usize,
714 in_flight: Option<InFlightReservation>,
718}
719
720impl<M, P, H> DemotingPolicyMemory<M, P, H> {
721 pub fn new(inner: M, policy: P, hook: H) -> Self {
724 Self {
725 inner,
726 policy,
727 hook,
728 state: StdMutex::new(HashMap::new()),
729 }
730 }
731}
732
733stateful_wrapper_common!(DemotingPolicyMemory, hook: H);
734
735impl<M, P, H> ConversationMemory for DemotingPolicyMemory<M, P, H>
736where
737 M: ConversationMemory,
738 P: MemoryPolicy,
739 H: DemotionHook,
740{
741 fn load<'a>(
742 &'a self,
743 conversation_id: &'a str,
744 ) -> WasmBoxedFuture<'a, Result<Vec<Message>, MemoryError>> {
745 Box::pin(async move {
746 let messages = self.inner.load(conversation_id).await?;
747 let (kept, mut demoted) = self.policy.apply_with_demoted(messages)?;
748 let demoted_count = demoted.len();
749
750 let (pending, reservation) = {
760 let mut guard = self.state.lock().map_err(poisoned)?;
761 if let Some(entry) = guard.get_mut(conversation_id) {
762 if entry.in_flight.is_some() {
763 return Ok(kept);
767 }
768 if entry.delivered >= demoted_count {
769 (Vec::new(), None)
770 } else {
771 let split = entry.delivered;
772 let reservation = Arc::new(());
773 entry.in_flight = Some(reservation.clone());
774 (demoted.split_off(split), Some(reservation))
775 }
776 } else if demoted_count == 0 {
777 (Vec::new(), None)
780 } else {
781 let reservation = Arc::new(());
782 guard.insert(
783 conversation_id.to_string(),
784 ConversationDemotionState {
785 delivered: 0,
786 in_flight: Some(reservation.clone()),
787 },
788 );
789 (std::mem::take(&mut demoted), Some(reservation))
790 }
791 };
792
793 let Some(reservation) = reservation else {
794 return Ok(kept);
795 };
796
797 let in_flight_guard =
803 InFlightGuard::new(&self.state, conversation_id, reservation.clone());
804
805 let result = self.hook.on_demote(conversation_id, pending).await;
806
807 release_in_flight(&self.state, conversation_id, &reservation, |entry| {
817 if result.is_ok() {
818 entry.delivered = demoted_count;
819 }
820 })?;
821 in_flight_guard.disarm();
822 result?;
823 Ok(kept)
824 })
825 }
826
827 stateful_wrapper_append_clear!();
828}
829
830fn poisoned<E: std::fmt::Display>(err: E) -> MemoryError {
831 MemoryError::Internal(err.to_string())
832}
833
834fn release_in_flight<S: InFlightSlot, T>(
840 state: &StdMutex<HashMap<String, S>>,
841 key: &str,
842 reservation: &InFlightReservation,
843 on_match: impl FnOnce(&mut S) -> T,
844) -> Result<Option<T>, MemoryError> {
845 let mut guard = state.lock().map_err(poisoned)?;
846 let Some(entry) = guard.get_mut(key) else {
847 return Ok(None);
848 };
849 if entry
850 .in_flight_mut()
851 .as_ref()
852 .is_some_and(|current| Arc::ptr_eq(current, reservation))
853 {
854 *entry.in_flight_mut() = None;
855 return Ok(Some(on_match(entry)));
856 }
857 Ok(None)
858}
859
860trait InFlightSlot {
864 fn in_flight_mut(&mut self) -> &mut Option<InFlightReservation>;
865}
866
867impl InFlightSlot for ConversationDemotionState {
868 fn in_flight_mut(&mut self) -> &mut Option<InFlightReservation> {
869 &mut self.in_flight
870 }
871}
872
873impl<A> InFlightSlot for ConversationCompactionState<A> {
874 fn in_flight_mut(&mut self) -> &mut Option<InFlightReservation> {
875 &mut self.in_flight
876 }
877}
878
879struct InFlightGuard<'a, S: InFlightSlot> {
890 state: &'a StdMutex<HashMap<String, S>>,
891 key: &'a str,
892 reservation: InFlightReservation,
893 armed: bool,
894}
895
896impl<'a, S: InFlightSlot> InFlightGuard<'a, S> {
897 fn new(
898 state: &'a StdMutex<HashMap<String, S>>,
899 key: &'a str,
900 reservation: InFlightReservation,
901 ) -> Self {
902 Self {
903 state,
904 key,
905 reservation,
906 armed: true,
907 }
908 }
909
910 fn disarm(mut self) {
913 self.armed = false;
914 }
915}
916
917impl<S: InFlightSlot> Drop for InFlightGuard<'_, S> {
918 fn drop(&mut self) {
919 if !self.armed {
920 return;
921 }
922 let _ = release_in_flight(self.state, self.key, &self.reservation, |_| ());
924 }
925}
926
927pub struct CompactingMemory<M, P, C: Compactor> {
997 inner: M,
998 policy: P,
999 compactor: C,
1000 state: StdMutex<HashMap<String, ConversationCompactionState<C::Artifact>>>,
1001}
1002
1003struct ConversationCompactionState<A> {
1004 summary: Option<A>,
1007 absorbed: usize,
1010 in_flight: Option<InFlightReservation>,
1014}
1015
1016impl<M, P, C: Compactor> CompactingMemory<M, P, C> {
1017 pub fn new(inner: M, policy: P, compactor: C) -> Self {
1020 Self {
1021 inner,
1022 policy,
1023 compactor,
1024 state: StdMutex::new(HashMap::new()),
1025 }
1026 }
1027}
1028
1029stateful_wrapper_common!(CompactingMemory, compactor: C: Compactor);
1030
1031impl<M, P, C> ConversationMemory for CompactingMemory<M, P, C>
1032where
1033 M: ConversationMemory,
1034 P: MemoryPolicy,
1035 C: Compactor,
1036{
1037 fn load<'a>(
1038 &'a self,
1039 conversation_id: &'a str,
1040 ) -> WasmBoxedFuture<'a, Result<Vec<Message>, MemoryError>> {
1041 Box::pin(async move {
1042 let messages = self.inner.load(conversation_id).await?;
1043 let (kept, demoted) = self.policy.apply_with_demoted(messages)?;
1044 let demoted_count = demoted.len();
1045
1046 let plan = {
1055 let mut guard = self.state.lock().map_err(poisoned)?;
1056 if let Some(entry) = guard.get_mut(conversation_id) {
1057 if entry.in_flight.is_some() {
1058 return Ok(splice(entry.summary.clone(), kept));
1062 }
1063 if demoted_count <= entry.absorbed {
1064 return Ok(splice(entry.summary.clone(), kept));
1067 }
1068 let reservation = Arc::new(());
1069 entry.in_flight = Some(reservation.clone());
1070 CompactionPlan {
1071 carry_over: entry.summary.clone(),
1072 skip: entry.absorbed,
1073 reservation,
1074 }
1075 } else if demoted_count == 0 {
1076 return Ok(kept);
1079 } else {
1080 let reservation = Arc::new(());
1081 guard.insert(
1082 conversation_id.to_string(),
1083 ConversationCompactionState {
1084 summary: None,
1085 absorbed: 0,
1086 in_flight: Some(reservation.clone()),
1087 },
1088 );
1089 CompactionPlan {
1090 carry_over: None,
1091 skip: 0,
1092 reservation,
1093 }
1094 }
1095 };
1096
1097 let CompactionPlan {
1102 carry_over,
1103 skip,
1104 reservation,
1105 } = plan;
1106
1107 let in_flight_guard =
1113 InFlightGuard::new(&self.state, conversation_id, reservation.clone());
1114
1115 let new_slice = match demoted.get(skip..) {
1116 Some(s) => s,
1117 None => {
1118 drop(in_flight_guard);
1121 return Err(MemoryError::Internal(
1122 "compaction watermark exceeds demoted slice length".into(),
1123 ));
1124 }
1125 };
1126
1127 let result = self
1128 .compactor
1129 .compact(conversation_id, new_slice, carry_over.as_ref())
1130 .await;
1131
1132 let summary_for_splice = match result {
1144 Ok(artifact) => {
1145 release_in_flight(&self.state, conversation_id, &reservation, |entry| {
1146 entry.absorbed = demoted_count;
1147 entry.summary = Some(artifact.clone());
1148 artifact
1149 })?
1150 }
1151 Err(err) => {
1152 release_in_flight(&self.state, conversation_id, &reservation, |_| ())?;
1153 return Err(err);
1154 }
1155 };
1156
1157 in_flight_guard.disarm();
1161
1162 Ok(splice(summary_for_splice, kept))
1163 })
1164 }
1165
1166 stateful_wrapper_append_clear!();
1167}
1168
1169struct CompactionPlan<A> {
1170 carry_over: Option<A>,
1171 skip: usize,
1172 reservation: InFlightReservation,
1173}
1174
1175fn splice<A>(summary: Option<A>, kept: Vec<Message>) -> Vec<Message>
1176where
1177 A: Into<Message>,
1178{
1179 match summary {
1180 Some(artifact) => {
1181 let mut out = Vec::with_capacity(kept.len() + 1);
1182 out.push(artifact.into());
1183 out.extend(kept);
1184 out
1185 }
1186 None => kept,
1187 }
1188}
1189
1190#[derive(Debug, Clone)]
1222pub struct TemplateCompactor {
1223 header: String,
1224 max_bytes: Option<usize>,
1225}
1226
1227impl TemplateCompactor {
1228 pub fn new() -> Self {
1231 Self::with_header("[Conversation summary so far]")
1232 }
1233
1234 pub fn with_header(header: impl Into<String>) -> Self {
1237 Self {
1238 header: header.into(),
1239 max_bytes: None,
1240 }
1241 }
1242
1243 pub fn with_max_bytes(mut self, max_bytes: usize) -> Self {
1252 self.max_bytes = if max_bytes == 0 {
1253 None
1254 } else {
1255 Some(max_bytes)
1256 };
1257 self
1258 }
1259}
1260
1261impl Default for TemplateCompactor {
1262 fn default() -> Self {
1263 Self::new()
1264 }
1265}
1266
1267#[derive(Debug, Clone)]
1274pub struct TextSummary(String);
1275
1276impl TextSummary {
1277 pub fn as_str(&self) -> &str {
1279 &self.0
1280 }
1281
1282 pub fn into_string(self) -> String {
1284 self.0
1285 }
1286}
1287
1288impl From<TextSummary> for Message {
1289 fn from(value: TextSummary) -> Self {
1290 Message::System { content: value.0 }
1291 }
1292}
1293
1294impl Compactor for TemplateCompactor {
1295 type Artifact = TextSummary;
1296
1297 fn compact<'a>(
1298 &'a self,
1299 _conversation_id: &'a str,
1300 evicted: &'a [Message],
1301 carry_over: Option<&'a Self::Artifact>,
1302 ) -> WasmBoxedFuture<'a, Result<Self::Artifact, MemoryError>> {
1303 Box::pin(async move {
1304 let mut buf = String::new();
1305 buf.push_str(&self.header);
1306 buf.push('\n');
1307 if let Some(prev) = carry_over {
1308 buf.push_str(prev.as_str());
1309 buf.push('\n');
1310 }
1311 for msg in evicted {
1312 let line = render_message_line(msg);
1313 if !line.is_empty() {
1314 buf.push_str(&line);
1315 buf.push('\n');
1316 }
1317 }
1318 if let Some(cap) = self.max_bytes
1319 && buf.len() > cap
1320 {
1321 buf = truncate_summary(&buf, cap);
1322 }
1323 Ok(TextSummary(buf))
1324 })
1325 }
1326}
1327
1328fn truncate_summary(buf: &str, cap: usize) -> String {
1336 const MARKER: &str = "[\u{2026}truncated\u{2026}]\n";
1337 let header_prefix_len = match buf.find('\n') {
1340 Some(i) => i + 1,
1341 None => return buf.to_string(),
1342 };
1343 if buf.len() <= header_prefix_len {
1344 return buf.to_string();
1345 }
1346 let preserved = header_prefix_len + MARKER.len();
1347 let keep_bytes = cap.saturating_sub(preserved);
1349 let body_start = header_prefix_len;
1350 let body = match buf.get(body_start..) {
1351 Some(b) => b,
1352 None => return buf.to_string(),
1353 };
1354 let mut cut = body.len().saturating_sub(keep_bytes);
1357 while cut < body.len() && !body.is_char_boundary(cut) {
1358 cut += 1;
1359 }
1360 let suffix: &str = body.get(cut..).unwrap_or_default();
1361 let header_with_nl = match buf.get(..header_prefix_len) {
1362 Some(h) => h,
1363 None => return buf.to_string(),
1364 };
1365 let mut out = String::with_capacity(header_prefix_len + MARKER.len() + suffix.len());
1366 out.push_str(header_with_nl);
1367 out.push_str(MARKER);
1368 out.push_str(suffix);
1369 out
1370}
1371
1372fn render_message_line(msg: &Message) -> String {
1378 use rig_core::message::AssistantContent;
1379
1380 let (role, text) = match msg {
1381 Message::System { content } => ("system", content.clone()),
1382 Message::User { content } => (
1383 "user",
1384 join_parts(content.iter().map(|item| match item {
1385 UserContent::Text(text) => Cow::Borrowed(text.text.as_str()),
1386 UserContent::ToolResult(_) => Cow::Borrowed("[tool result]"),
1387 _ => Cow::Borrowed("[attachment]"),
1388 })),
1389 ),
1390 Message::Assistant { content, .. } => (
1391 "assistant",
1392 join_parts(content.iter().map(|item| match item {
1393 AssistantContent::Text(text) => Cow::Borrowed(text.text.as_str()),
1394 AssistantContent::ToolCall(call) => {
1395 Cow::Owned(format!("[tool call: {}]", call.function.name))
1396 }
1397 _ => Cow::Borrowed("[reasoning]"),
1398 })),
1399 ),
1400 };
1401
1402 if text.is_empty() {
1403 String::new()
1404 } else {
1405 format!("{role}: {text}")
1406 }
1407}
1408
1409fn join_parts<'a>(parts: impl Iterator<Item = Cow<'a, str>>) -> String {
1417 let mut text = String::new();
1418 for part in parts {
1419 if !text.is_empty() {
1420 text.push(' ');
1421 }
1422 text.push_str(&part);
1423 }
1424 text
1425}
1426
1427#[cfg(test)]
1428mod tests {
1429 use super::*;
1430 use rig_core::message::{
1431 AssistantContent, ToolCall, ToolCallId, ToolFunction, ToolResult, ToolResultContent,
1432 UserContent,
1433 };
1434 use std::sync::Mutex;
1435
1436 fn user(text: &str) -> Message {
1437 Message::user(text)
1438 }
1439
1440 fn assistant(text: &str) -> Message {
1441 Message::assistant(text)
1442 }
1443
1444 fn tool_call_msg() -> Message {
1445 Message::Assistant {
1446 id: None,
1447 content: vec![AssistantContent::ToolCall(ToolCall::new(
1448 ToolCallId::new_or_mint("call_1"),
1449 ToolFunction::new("t".into(), serde_json::json!({})),
1450 ))],
1451 }
1452 }
1453
1454 fn tool_result_msg() -> Message {
1455 Message::User {
1456 content: vec![UserContent::ToolResult(ToolResult {
1457 call: ToolCallId::new_or_mint("call_1"),
1458 provider: None,
1459 name: "t".into(),
1460 content: vec![ToolResultContent::text("ok")],
1461 })],
1462 }
1463 }
1464
1465 #[test]
1466 fn noop_policy_is_identity() {
1467 let msgs = vec![user("a"), assistant("b")];
1468 let out = NoopMemoryPolicy.apply(msgs).unwrap();
1469 assert_eq!(out.len(), 2);
1470 }
1471
1472 #[test]
1473 fn sliding_window_passthrough_when_under_limit() {
1474 let policy = SlidingWindowMemory::last_messages(5);
1475 let out = policy.apply(vec![user("1"), assistant("2")]).unwrap();
1476 assert_eq!(out.len(), 2);
1477 }
1478
1479 #[tokio::test]
1480 async fn sliding_window_truncates_via_filter() {
1481 let mem = InMemoryConversationMemory::new()
1482 .with_filter(SlidingWindowMemory::last_messages(2).into_filter());
1483
1484 mem.append(
1485 "c",
1486 vec![user("1"), assistant("2"), user("3"), assistant("4")],
1487 )
1488 .await
1489 .unwrap();
1490
1491 let loaded = mem.load("c").await.unwrap();
1492 assert_eq!(loaded.len(), 2);
1493 }
1494
1495 #[test]
1496 fn sliding_window_drops_leading_orphan_tool_result() {
1497 let policy = SlidingWindowMemory::last_messages(3);
1498 let out = policy
1499 .apply(vec![
1500 tool_call_msg(),
1501 tool_result_msg(),
1502 user("after"),
1503 assistant("done"),
1504 ])
1505 .unwrap();
1506
1507 assert_eq!(out.len(), 2);
1508 assert!(matches!(out.first(), Some(Message::User { content })
1509 if matches!(content.first(), Some(UserContent::Text(_)))));
1510 }
1511
1512 #[test]
1513 fn token_window_keeps_within_budget() {
1514 let msgs = vec![
1515 user("aaaa"),
1516 assistant("bbbb"),
1517 user("cccc"),
1518 assistant("dddd"),
1519 ];
1520 let policy = TokenWindowMemory::new(2, |_: &Message| 1);
1521 let out = policy.apply(msgs).unwrap();
1522 assert_eq!(out.len(), 2);
1523 }
1524
1525 #[test]
1526 fn token_window_passes_through_when_under_budget() {
1527 let msgs = vec![user("a"), assistant("b")];
1528 let policy = TokenWindowMemory::new(usize::MAX, |_: &Message| 1);
1529 let out = policy.apply(msgs).unwrap();
1530 assert_eq!(out.len(), 2);
1531 }
1532
1533 #[test]
1534 fn token_window_drops_leading_orphan_tool_result() {
1535 let policy = TokenWindowMemory::new(25, |_: &Message| 10);
1536 let out = policy
1537 .apply(vec![tool_call_msg(), tool_result_msg(), user("after")])
1538 .unwrap();
1539 assert_eq!(out.len(), 1);
1540 assert!(matches!(out.first(), Some(Message::User { content })
1541 if matches!(content.first(), Some(UserContent::Text(_)))));
1542 }
1543
1544 #[test]
1545 fn token_window_skips_message_larger_than_budget() {
1546 let policy = TokenWindowMemory::new(5, |_: &Message| 10);
1547 let out = policy.apply(vec![user("anything")]).unwrap();
1548 assert!(out.is_empty());
1549 }
1550
1551 #[test]
1552 fn heuristic_counter_charges_overhead_per_message() {
1553 let counter = HeuristicTokenCounter::default();
1554 let empty = counter.count(&user(""));
1555 assert!(
1556 empty >= 4,
1557 "default per-message overhead is at least 4 tokens"
1558 );
1559 }
1560
1561 #[test]
1562 fn heuristic_counter_is_monotonic_in_text_length() {
1563 let counter = HeuristicTokenCounter::default();
1564 let small = counter.count(&user("hi"));
1565 let big = counter.count(&user(&"x".repeat(400)));
1566 assert!(big > small);
1567 }
1568
1569 #[test]
1570 fn heuristic_counter_handles_tool_calls() {
1571 let counter = HeuristicTokenCounter::default();
1572 let cost = counter.count(&tool_call_msg());
1573 assert!(cost > 0);
1574 }
1575
1576 #[test]
1577 fn heuristic_counter_handles_system_messages() {
1578 let counter = HeuristicTokenCounter::default();
1579 let cost = counter.count(&Message::System {
1580 content: "you are helpful".into(),
1581 });
1582 assert!(cost > 0);
1583 }
1584
1585 #[test]
1586 fn heuristic_counter_clamps_invalid_bytes_per_token() {
1587 let counter = HeuristicTokenCounter::new(0.0, 0, 0);
1589 assert!(counter.count(&user("abcd")) >= 4);
1590 let nan = HeuristicTokenCounter::new(f32::NAN, 0, 0);
1591 assert!(nan.count(&user("abcd")) >= 4);
1592 }
1593
1594 #[test]
1595 fn heuristic_counter_drives_token_window() {
1596 let policy = TokenWindowMemory::new(100, HeuristicTokenCounter::default());
1597 let msgs = vec![user(&"a".repeat(2_000)), user("short")];
1598 let out = policy.apply(msgs).unwrap();
1599 assert_eq!(out.len(), 1);
1601 }
1602
1603 #[test]
1604 fn arc_token_counter_can_drive_token_window() {
1605 let counter: Arc<dyn TokenCounter> = Arc::new(|_: &Message| 1);
1606 let policy = TokenWindowMemory::new(2, counter);
1607 let out = policy
1608 .apply(vec![user("a"), assistant("b"), user("c")])
1609 .unwrap();
1610
1611 assert_eq!(out.len(), 2);
1612 }
1613
1614 #[test]
1615 fn boxed_token_counter_forwards_count() {
1616 let counter: Box<dyn TokenCounter> = Box::new(|_: &Message| 7);
1617 assert_eq!(counter.count(&user("a")), 7);
1618 }
1619
1620 #[test]
1621 fn into_filter_returns_input_on_policy_error() {
1622 struct FailingPolicy;
1623 impl MemoryPolicy for FailingPolicy {
1624 fn apply(&self, _: Vec<Message>) -> Result<Vec<Message>, MemoryError> {
1625 Err(MemoryError::Policy("intentional failure".into()))
1626 }
1627 }
1628
1629 let filter = FailingPolicy.into_filter();
1630 let input = vec![user("a"), assistant("b"), user("c")];
1631 let out = filter(input.clone());
1632 assert_eq!(
1633 out.len(),
1634 input.len(),
1635 "history must be preserved on policy error"
1636 );
1637 }
1638
1639 #[tokio::test]
1640 async fn policy_memory_truncates_loaded_history() {
1641 let mem = PolicyMemory::new(
1642 InMemoryConversationMemory::new(),
1643 SlidingWindowMemory::last_messages(2),
1644 );
1645
1646 mem.append(
1647 "c",
1648 vec![user("1"), assistant("2"), user("3"), assistant("4")],
1649 )
1650 .await
1651 .unwrap();
1652
1653 let loaded = mem.load("c").await.unwrap();
1654 assert_eq!(loaded.len(), 2);
1655 }
1656
1657 #[tokio::test]
1658 async fn policy_memory_propagates_policy_errors() {
1659 struct FailingPolicy;
1660 impl MemoryPolicy for FailingPolicy {
1661 fn apply(&self, _: Vec<Message>) -> Result<Vec<Message>, MemoryError> {
1662 Err(MemoryError::Policy("intentional failure".into()))
1663 }
1664 }
1665
1666 let mem = PolicyMemory::new(InMemoryConversationMemory::new(), FailingPolicy);
1667 mem.append("c", vec![user("1"), assistant("2")])
1668 .await
1669 .unwrap();
1670
1671 let result = mem.load("c").await;
1672 assert!(matches!(result, Err(MemoryError::Policy(_))));
1673 }
1674
1675 #[tokio::test]
1676 async fn policy_memory_append_and_clear_delegate_to_inner() {
1677 let mem = PolicyMemory::new(InMemoryConversationMemory::new(), NoopMemoryPolicy);
1678 mem.append("c", vec![user("hi"), assistant("ok")])
1679 .await
1680 .unwrap();
1681 assert_eq!(mem.load("c").await.unwrap().len(), 2);
1682
1683 mem.clear("c").await.unwrap();
1684 assert!(mem.load("c").await.unwrap().is_empty());
1685 }
1686
1687 #[test]
1688 fn sliding_window_reports_demoted_prefix() {
1689 let policy = SlidingWindowMemory::last_messages(2);
1690 let (kept, demoted) = policy
1691 .apply_with_demoted(vec![
1692 user("oldest"),
1693 assistant("old"),
1694 user("recent"),
1695 assistant("latest"),
1696 ])
1697 .unwrap();
1698 assert_eq!(kept.len(), 2);
1699 assert_eq!(demoted.len(), 2);
1700 }
1701
1702 #[test]
1703 fn token_window_reports_demoted_prefix() {
1704 let policy = TokenWindowMemory::new(2, |_: &Message| 1);
1705 let (kept, demoted) = policy
1706 .apply_with_demoted(vec![user("a"), assistant("b"), user("c"), assistant("d")])
1707 .unwrap();
1708 assert_eq!(kept.len(), 2);
1709 assert_eq!(demoted.len(), 2);
1710 }
1711
1712 #[test]
1713 fn noop_policy_demotes_nothing() {
1714 let (kept, demoted) = NoopMemoryPolicy
1715 .apply_with_demoted(vec![user("a"), assistant("b")])
1716 .unwrap();
1717 assert_eq!(kept.len(), 2);
1718 assert!(demoted.is_empty());
1719 }
1720
1721 #[test]
1722 fn arc_memory_policy_preserves_demoted_metadata() {
1723 let policy: Arc<dyn MemoryPolicy> = Arc::new(SlidingWindowMemory::last_messages(1));
1724 let (kept, demoted) = policy
1725 .apply_with_demoted(vec![user("old"), assistant("new")])
1726 .unwrap();
1727
1728 assert_eq!(kept.len(), 1);
1729 assert_eq!(demoted.len(), 1);
1730 }
1731
1732 #[test]
1733 fn boxed_memory_policy_preserves_demoted_metadata() {
1734 let policy: Box<dyn MemoryPolicy> = Box::new(SlidingWindowMemory::last_messages(1));
1735 let (kept, demoted) = policy
1736 .apply_with_demoted(vec![user("old"), assistant("new")])
1737 .unwrap();
1738
1739 assert_eq!(kept.len(), 1);
1740 assert_eq!(demoted.len(), 1);
1741 }
1742
1743 #[test]
1744 fn sliding_window_demotes_orphan_tool_result_with_prefix() {
1745 let policy = SlidingWindowMemory::last_messages(2);
1749 let (kept, demoted) = policy
1750 .apply_with_demoted(vec![
1751 tool_call_msg(),
1752 tool_result_msg(),
1753 user("after"),
1754 assistant("done"),
1755 ])
1756 .unwrap();
1757 assert_eq!(kept.len(), 2);
1758 assert!(matches!(kept.first(), Some(Message::User { content })
1759 if matches!(content.first(), Some(UserContent::Text(_)))));
1760 assert_eq!(demoted.len(), 2);
1761 }
1762
1763 #[derive(Default)]
1764 struct CountingHook {
1765 seen: Mutex<Vec<(String, Vec<Message>)>>,
1766 }
1767
1768 impl CountingHook {
1769 fn calls(&self) -> usize {
1770 self.seen.lock().unwrap().len()
1771 }
1772 fn last_demoted_count(&self) -> usize {
1773 self.seen
1774 .lock()
1775 .unwrap()
1776 .last()
1777 .map(|(_, m)| m.len())
1778 .unwrap_or(0)
1779 }
1780 }
1781
1782 impl DemotionHook for CountingHook {
1783 fn on_demote<'a>(
1784 &'a self,
1785 conversation_id: &'a str,
1786 messages: Vec<Message>,
1787 ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
1788 Box::pin(async move {
1789 self.seen
1790 .lock()
1791 .unwrap()
1792 .push((conversation_id.to_string(), messages));
1793 Ok(())
1794 })
1795 }
1796 }
1797
1798 #[tokio::test]
1799 async fn demoting_policy_memory_invokes_hook_on_truncation() {
1800 let hook = Arc::new(CountingHook::default());
1801 let mem = DemotingPolicyMemory::new(
1802 InMemoryConversationMemory::new(),
1803 SlidingWindowMemory::last_messages(2),
1804 hook.clone(),
1805 );
1806
1807 mem.append(
1808 "c",
1809 vec![user("1"), assistant("2"), user("3"), assistant("4")],
1810 )
1811 .await
1812 .unwrap();
1813
1814 let kept = mem.load("c").await.unwrap();
1815 assert_eq!(kept.len(), 2);
1816 assert_eq!(hook.calls(), 1);
1817 assert_eq!(hook.last_demoted_count(), 2);
1818 }
1819
1820 #[tokio::test]
1821 async fn demoting_policy_memory_does_not_replay_demotions() {
1822 let hook = Arc::new(CountingHook::default());
1823 let mem = DemotingPolicyMemory::new(
1824 InMemoryConversationMemory::new(),
1825 SlidingWindowMemory::last_messages(2),
1826 hook.clone(),
1827 );
1828
1829 mem.append(
1830 "c",
1831 vec![user("1"), assistant("2"), user("3"), assistant("4")],
1832 )
1833 .await
1834 .unwrap();
1835
1836 mem.load("c").await.unwrap();
1837 mem.load("c").await.unwrap();
1838 assert_eq!(hook.calls(), 1);
1839 assert_eq!(hook.last_demoted_count(), 2);
1840 }
1841
1842 #[tokio::test]
1843 async fn demoting_policy_memory_only_reports_newly_demoted_messages() {
1844 let hook = Arc::new(CountingHook::default());
1845 let mem = DemotingPolicyMemory::new(
1846 InMemoryConversationMemory::new(),
1847 SlidingWindowMemory::last_messages(2),
1848 hook.clone(),
1849 );
1850
1851 mem.append(
1852 "c",
1853 vec![user("1"), assistant("2"), user("3"), assistant("4")],
1854 )
1855 .await
1856 .unwrap();
1857 mem.load("c").await.unwrap();
1858
1859 mem.append("c", vec![user("5")]).await.unwrap();
1860 mem.load("c").await.unwrap();
1861
1862 assert_eq!(hook.calls(), 2);
1863 assert_eq!(hook.last_demoted_count(), 1);
1864 }
1865
1866 #[derive(Default)]
1867 struct FailingHook {
1868 calls: Mutex<usize>,
1869 }
1870
1871 impl DemotionHook for FailingHook {
1872 fn on_demote<'a>(
1873 &'a self,
1874 _conversation_id: &'a str,
1875 _messages: Vec<Message>,
1876 ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
1877 Box::pin(async move {
1878 *self.calls.lock().unwrap() += 1;
1879 Err(MemoryError::backend(std::io::Error::other("hook failed")))
1880 })
1881 }
1882 }
1883
1884 #[tokio::test]
1885 async fn demoting_policy_memory_does_not_advance_watermark_on_hook_failure() {
1886 let hook = Arc::new(FailingHook::default());
1887 let mem = DemotingPolicyMemory::new(
1888 InMemoryConversationMemory::new(),
1889 SlidingWindowMemory::last_messages(1),
1890 hook.clone(),
1891 );
1892 mem.append("c", vec![user("1"), assistant("2")])
1893 .await
1894 .unwrap();
1895
1896 assert!(mem.load("c").await.is_err());
1897 assert!(mem.load("c").await.is_err());
1898 assert_eq!(*hook.calls.lock().unwrap(), 2);
1899 }
1900
1901 #[tokio::test]
1902 async fn demoting_policy_memory_clear_resets_watermark() {
1903 let hook = Arc::new(CountingHook::default());
1904 let mem = DemotingPolicyMemory::new(
1905 InMemoryConversationMemory::new(),
1906 SlidingWindowMemory::last_messages(1),
1907 hook.clone(),
1908 );
1909
1910 mem.append("c", vec![user("1"), assistant("2")])
1911 .await
1912 .unwrap();
1913 mem.load("c").await.unwrap();
1914 mem.clear("c").await.unwrap();
1915 mem.append("c", vec![user("3"), assistant("4")])
1916 .await
1917 .unwrap();
1918 mem.load("c").await.unwrap();
1919
1920 assert_eq!(hook.calls(), 2);
1921 assert_eq!(hook.last_demoted_count(), 1);
1922 }
1923
1924 #[tokio::test]
1925 async fn demoting_policy_memory_skips_hook_when_nothing_evicted() {
1926 let hook = Arc::new(CountingHook::default());
1927 let mem = DemotingPolicyMemory::new(
1928 InMemoryConversationMemory::new(),
1929 SlidingWindowMemory::last_messages(10),
1930 hook.clone(),
1931 );
1932
1933 mem.append("c", vec![user("1"), assistant("2")])
1934 .await
1935 .unwrap();
1936 mem.load("c").await.unwrap();
1937 assert_eq!(hook.calls(), 0);
1938 }
1939
1940 #[tokio::test]
1941 async fn demoting_policy_memory_with_noop_hook_behaves_like_policy_memory() {
1942 let mem = DemotingPolicyMemory::new(
1943 InMemoryConversationMemory::new(),
1944 SlidingWindowMemory::last_messages(1),
1945 NoopDemotionHook,
1946 );
1947 mem.append("c", vec![user("a"), assistant("b"), user("c")])
1948 .await
1949 .unwrap();
1950 assert_eq!(mem.load("c").await.unwrap().len(), 1);
1951 }
1952
1953 struct GatedHook {
1956 calls: Arc<std::sync::atomic::AtomicUsize>,
1957 rendezvous: Arc<tokio::sync::Notify>,
1958 release: Arc<tokio::sync::Notify>,
1959 }
1960
1961 impl DemotionHook for GatedHook {
1962 fn on_demote<'a>(
1963 &'a self,
1964 _conversation_id: &'a str,
1965 _messages: Vec<Message>,
1966 ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
1967 let calls = self.calls.clone();
1968 let rendezvous = self.rendezvous.clone();
1969 let release = self.release.clone();
1970 Box::pin(async move {
1971 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1972 rendezvous.notify_one();
1973 release.notified().await;
1974 Ok(())
1975 })
1976 }
1977 }
1978
1979 #[tokio::test]
1980 async fn demoting_policy_memory_serialises_concurrent_loads() {
1981 use std::sync::atomic::{AtomicUsize, Ordering};
1982
1983 let calls = Arc::new(AtomicUsize::new(0));
1984 let rendezvous = Arc::new(tokio::sync::Notify::new());
1985 let release = Arc::new(tokio::sync::Notify::new());
1986 let hook = GatedHook {
1987 calls: calls.clone(),
1988 rendezvous: rendezvous.clone(),
1989 release: release.clone(),
1990 };
1991
1992 let mem = Arc::new(DemotingPolicyMemory::new(
1993 InMemoryConversationMemory::new(),
1994 SlidingWindowMemory::last_messages(1),
1995 hook,
1996 ));
1997
1998 mem.append("c", vec![user("1"), assistant("2"), user("3")])
1999 .await
2000 .unwrap();
2001
2002 let m1 = mem.clone();
2003 let first = tokio::spawn(async move { m1.load("c").await });
2004
2005 rendezvous.notified().await;
2007 assert_eq!(calls.load(Ordering::SeqCst), 1);
2008
2009 let kept = mem.load("c").await.unwrap();
2012 assert_eq!(kept.len(), 1);
2013 assert_eq!(calls.load(Ordering::SeqCst), 1, "hook must not double-fire");
2014
2015 release.notify_one();
2017 let kept_first = first.await.unwrap().unwrap();
2018 assert_eq!(kept_first.len(), 1);
2019 assert_eq!(calls.load(Ordering::SeqCst), 1);
2020
2021 mem.load("c").await.unwrap();
2023 assert_eq!(calls.load(Ordering::SeqCst), 1);
2024 }
2025
2026 #[tokio::test]
2027 async fn demoting_policy_memory_dropped_load_releases_in_flight_gate() {
2028 use std::sync::atomic::{AtomicUsize, Ordering};
2032
2033 let calls = Arc::new(AtomicUsize::new(0));
2034 let rendezvous = Arc::new(tokio::sync::Notify::new());
2035 let release = Arc::new(tokio::sync::Notify::new());
2036 let hook = GatedHook {
2037 calls: calls.clone(),
2038 rendezvous,
2039 release: release.clone(),
2040 };
2041
2042 let mem = Arc::new(DemotingPolicyMemory::new(
2043 InMemoryConversationMemory::new(),
2044 SlidingWindowMemory::last_messages(1),
2045 hook,
2046 ));
2047
2048 mem.append("c", vec![user("1"), assistant("2"), user("3")])
2049 .await
2050 .unwrap();
2051
2052 let mem_load = mem.clone();
2056 let handle = tokio::spawn(async move { mem_load.load("c").await });
2057 while calls.load(Ordering::SeqCst) == 0 {
2058 tokio::task::yield_now().await;
2059 }
2060 handle.abort();
2061 let _ = handle.await;
2062
2063 let mem_load = mem.clone();
2068 let retry = tokio::spawn(async move { mem_load.load("c").await });
2069 for _ in 0..1_000 {
2070 if calls.load(Ordering::SeqCst) >= 2 {
2071 break;
2072 }
2073 tokio::task::yield_now().await;
2074 }
2075 assert_eq!(
2076 calls.load(Ordering::SeqCst),
2077 2,
2078 "retry must re-enter the hook after cancellation"
2079 );
2080
2081 release.notify_one();
2082 let kept = retry.await.unwrap().unwrap();
2083 assert_eq!(kept.len(), 1);
2084
2085 mem.load("c").await.unwrap();
2088 assert_eq!(calls.load(Ordering::SeqCst), 2);
2089 }
2090
2091 #[tokio::test]
2092 async fn demoting_stale_cancelled_load_does_not_clear_new_reservation() {
2093 use std::sync::atomic::{AtomicUsize, Ordering};
2094
2095 let calls = Arc::new(AtomicUsize::new(0));
2096 let rendezvous = Arc::new(tokio::sync::Notify::new());
2097 let release = Arc::new(tokio::sync::Notify::new());
2098 let hook = GatedHook {
2099 calls: calls.clone(),
2100 rendezvous: rendezvous.clone(),
2101 release: release.clone(),
2102 };
2103
2104 let mem = Arc::new(DemotingPolicyMemory::new(
2105 InMemoryConversationMemory::new(),
2106 SlidingWindowMemory::last_messages(1),
2107 hook,
2108 ));
2109
2110 mem.append("c", vec![user("old 1"), assistant("old 2"), user("old 3")])
2111 .await
2112 .unwrap();
2113
2114 let mem_load = mem.clone();
2115 let stale = tokio::spawn(async move { mem_load.load("c").await });
2116 rendezvous.notified().await;
2117 assert_eq!(calls.load(Ordering::SeqCst), 1);
2118
2119 mem.clear("c").await.unwrap();
2120 mem.append(
2121 "c",
2122 vec![user("fresh 1"), assistant("fresh 2"), user("fresh 3")],
2123 )
2124 .await
2125 .unwrap();
2126
2127 let mem_load = mem.clone();
2128 let fresh = tokio::spawn(async move { mem_load.load("c").await });
2129 rendezvous.notified().await;
2130 assert_eq!(calls.load(Ordering::SeqCst), 2);
2131
2132 stale.abort();
2133 let _ = stale.await;
2134
2135 let mem_load = mem.clone();
2136 let mut concurrent = tokio::spawn(async move { mem_load.load("c").await });
2137 let concurrent_kept = tokio::select! {
2138 result = &mut concurrent => result.unwrap().unwrap(),
2139 _ = rendezvous.notified() => {
2140 panic!("stale guard must not clear the fresh in-flight reservation")
2141 }
2142 };
2143 assert_eq!(
2144 calls.load(Ordering::SeqCst),
2145 2,
2146 "stale guard must not clear the fresh in-flight reservation"
2147 );
2148
2149 release.notify_one();
2150 assert_eq!(fresh.await.unwrap().unwrap().len(), 1);
2151 assert_eq!(concurrent_kept.len(), 1);
2152 assert_eq!(calls.load(Ordering::SeqCst), 2);
2153 }
2154
2155 #[tokio::test]
2156 async fn demoting_stale_successful_load_does_not_clear_new_reservation() {
2157 #[derive(Default)]
2158 struct IndividuallyGatedHook {
2159 releases: Mutex<Vec<Arc<tokio::sync::Notify>>>,
2160 }
2161
2162 impl IndividuallyGatedHook {
2163 fn call_count(&self) -> usize {
2164 self.releases.lock().unwrap().len()
2165 }
2166
2167 async fn wait_for_call_count(&self, expected: usize) {
2168 while self.call_count() < expected {
2169 tokio::task::yield_now().await;
2170 }
2171 }
2172
2173 fn release_call(&self, index: usize) {
2174 let release = self.releases.lock().unwrap()[index].clone();
2175 release.notify_one();
2176 }
2177 }
2178
2179 impl DemotionHook for IndividuallyGatedHook {
2180 fn on_demote<'a>(
2181 &'a self,
2182 _conversation_id: &'a str,
2183 _messages: Vec<Message>,
2184 ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
2185 let release = Arc::new(tokio::sync::Notify::new());
2186 self.releases.lock().unwrap().push(release.clone());
2187 Box::pin(async move {
2188 release.notified().await;
2189 Ok(())
2190 })
2191 }
2192 }
2193
2194 let hook = Arc::new(IndividuallyGatedHook::default());
2195 let mem = Arc::new(DemotingPolicyMemory::new(
2196 InMemoryConversationMemory::new(),
2197 SlidingWindowMemory::last_messages(1),
2198 hook.clone(),
2199 ));
2200
2201 mem.append("c", vec![user("old 1"), assistant("old 2"), user("old 3")])
2202 .await
2203 .unwrap();
2204
2205 let mem_load = mem.clone();
2206 let stale = tokio::spawn(async move { mem_load.load("c").await });
2207 hook.wait_for_call_count(1).await;
2208
2209 mem.clear("c").await.unwrap();
2210 mem.append(
2211 "c",
2212 vec![user("fresh 1"), assistant("fresh 2"), user("fresh 3")],
2213 )
2214 .await
2215 .unwrap();
2216
2217 let mem_load = mem.clone();
2218 let fresh = tokio::spawn(async move { mem_load.load("c").await });
2219 hook.wait_for_call_count(2).await;
2220
2221 hook.release_call(0);
2225 assert_eq!(stale.await.unwrap().unwrap().len(), 1);
2226 assert_eq!(hook.call_count(), 2);
2227
2228 let mem_load = mem.clone();
2229 let mut concurrent = tokio::spawn(async move { mem_load.load("c").await });
2230 let hook_wait = hook.clone();
2231 let concurrent_kept = tokio::select! {
2232 result = &mut concurrent => result.unwrap().unwrap(),
2233 _ = hook_wait.wait_for_call_count(3) => {
2234 panic!("stale successful load must not clear the fresh in-flight reservation")
2235 }
2236 };
2237 assert_eq!(
2238 hook.call_count(),
2239 2,
2240 "stale successful load must not clear the fresh in-flight reservation"
2241 );
2242
2243 hook.release_call(1);
2244 assert_eq!(fresh.await.unwrap().unwrap().len(), 1);
2245 assert_eq!(concurrent_kept.len(), 1);
2246
2247 mem.load("c").await.unwrap();
2248 assert_eq!(hook.call_count(), 2);
2249 }
2250
2251 #[tokio::test]
2252 async fn forget_drops_in_process_watermark() {
2253 let hook = Arc::new(CountingHook::default());
2254 let mem = DemotingPolicyMemory::new(
2255 InMemoryConversationMemory::new(),
2256 SlidingWindowMemory::last_messages(1),
2257 hook.clone(),
2258 );
2259
2260 mem.append("c", vec![user("1"), assistant("2")])
2261 .await
2262 .unwrap();
2263 mem.load("c").await.unwrap();
2264 assert_eq!(mem.tracked_conversations(), 1);
2265 assert_eq!(hook.calls(), 1);
2266
2267 mem.forget("c");
2272 assert_eq!(mem.tracked_conversations(), 0);
2273 mem.load("c").await.unwrap();
2274 assert_eq!(hook.calls(), 2);
2275 }
2276
2277 #[tokio::test]
2282 async fn compacting_no_demotion_returns_kept_only() {
2283 let mem = CompactingMemory::new(
2284 InMemoryConversationMemory::new(),
2285 SlidingWindowMemory::last_messages(10),
2286 TemplateCompactor::new(),
2287 );
2288
2289 mem.append("c", vec![user("hi"), assistant("hello")])
2290 .await
2291 .unwrap();
2292 let loaded = mem.load("c").await.unwrap();
2293 assert_eq!(loaded.len(), 2);
2294 assert!(matches!(&loaded[0], Message::User { .. }));
2298 }
2299
2300 #[tokio::test]
2301 async fn compacting_splices_summary_when_demoted() {
2302 let mem = CompactingMemory::new(
2303 InMemoryConversationMemory::new(),
2304 SlidingWindowMemory::last_messages(2),
2305 TemplateCompactor::new(),
2306 );
2307
2308 mem.append(
2309 "c",
2310 vec![
2311 user("first"),
2312 assistant("second"),
2313 user("third"),
2314 assistant("fourth"),
2315 ],
2316 )
2317 .await
2318 .unwrap();
2319
2320 let loaded = mem.load("c").await.unwrap();
2321 assert_eq!(loaded.len(), 3);
2323 let Message::System { content } = &loaded[0] else {
2324 panic!("expected summary as system message");
2325 };
2326 assert!(content.contains("[Conversation summary so far]"));
2327 assert!(content.contains("user: first"));
2328 assert!(content.contains("assistant: second"));
2329 let Message::User { content } = &loaded[1] else {
2331 panic!("expected kept user message");
2332 };
2333 let Some(UserContent::Text(t)) = content.first() else {
2334 panic!("expected text");
2335 };
2336 assert_eq!(t.text, "third");
2337 }
2338
2339 #[tokio::test]
2340 async fn compacting_rolls_summary_forward() {
2341 let mem = CompactingMemory::new(
2342 InMemoryConversationMemory::new(),
2343 SlidingWindowMemory::last_messages(2),
2344 TemplateCompactor::new(),
2345 );
2346
2347 mem.append(
2348 "c",
2349 vec![user("a"), assistant("b"), user("c"), assistant("d")],
2350 )
2351 .await
2352 .unwrap();
2353
2354 let first = mem.load("c").await.unwrap();
2355 let Message::System { content } = &first[0] else {
2356 panic!("summary missing");
2357 };
2358 let first_summary = content.clone();
2359 assert!(first_summary.contains("user: a"));
2360 assert!(first_summary.contains("assistant: b"));
2361
2362 mem.append("c", vec![user("e"), assistant("f")])
2365 .await
2366 .unwrap();
2367 let second = mem.load("c").await.unwrap();
2368 let Message::System { content } = &second[0] else {
2369 panic!("summary missing");
2370 };
2371 assert!(content.contains(&first_summary));
2374 assert!(content.contains("user: c"));
2375 assert!(content.contains("assistant: d"));
2376 }
2377
2378 #[tokio::test]
2379 async fn compacting_idempotent_within_process() {
2380 let mem = CompactingMemory::new(
2385 InMemoryConversationMemory::new(),
2386 SlidingWindowMemory::last_messages(1),
2387 TemplateCompactor::new(),
2388 );
2389 mem.append("c", vec![user("a"), assistant("b"), user("c")])
2390 .await
2391 .unwrap();
2392
2393 let first = mem.load("c").await.unwrap();
2394 let second = mem.load("c").await.unwrap();
2395 assert_eq!(first.len(), second.len());
2396 let Message::System { content: c1 } = &first[0] else {
2397 panic!()
2398 };
2399 let Message::System { content: c2 } = &second[0] else {
2400 panic!()
2401 };
2402 assert_eq!(c1, c2);
2403 }
2404
2405 #[tokio::test]
2406 async fn compacting_clear_drops_summary() {
2407 let mem = CompactingMemory::new(
2408 InMemoryConversationMemory::new(),
2409 SlidingWindowMemory::last_messages(1),
2410 TemplateCompactor::new(),
2411 );
2412 mem.append("c", vec![user("a"), assistant("b"), user("c")])
2413 .await
2414 .unwrap();
2415 mem.load("c").await.unwrap();
2416 assert_eq!(mem.tracked_conversations(), 1);
2417
2418 mem.clear("c").await.unwrap();
2419 assert_eq!(mem.tracked_conversations(), 0);
2420 assert!(mem.load("c").await.unwrap().is_empty());
2421 }
2422
2423 #[derive(Default)]
2426 struct FlakyCompactor {
2427 calls: std::sync::atomic::AtomicUsize,
2428 }
2429
2430 impl Compactor for FlakyCompactor {
2431 type Artifact = TextSummary;
2432
2433 fn compact<'a>(
2434 &'a self,
2435 _conversation_id: &'a str,
2436 evicted: &'a [Message],
2437 _carry_over: Option<&'a Self::Artifact>,
2438 ) -> WasmBoxedFuture<'a, Result<Self::Artifact, MemoryError>> {
2439 Box::pin(async move {
2440 let n = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2441 if n == 0 {
2442 Err(MemoryError::Policy("flaky".into()))
2443 } else {
2444 Ok(TextSummary(format!("compacted {} messages", evicted.len())))
2445 }
2446 })
2447 }
2448 }
2449
2450 #[tokio::test]
2451 async fn compacting_failure_does_not_advance_watermark() {
2452 let mem = CompactingMemory::new(
2453 InMemoryConversationMemory::new(),
2454 SlidingWindowMemory::last_messages(1),
2455 FlakyCompactor::default(),
2456 );
2457 mem.append("c", vec![user("a"), assistant("b"), user("c")])
2458 .await
2459 .unwrap();
2460
2461 let err = mem.load("c").await.unwrap_err();
2462 assert!(matches!(err, MemoryError::Policy(_)));
2463
2464 let loaded = mem.load("c").await.unwrap();
2466 assert_eq!(loaded.len(), 2);
2467 let Message::System { content } = &loaded[0] else {
2468 panic!("expected summary")
2469 };
2470 assert!(content.contains("compacted"));
2471 }
2472
2473 #[derive(Default)]
2476 struct CountingCompactor {
2477 log: Mutex<Vec<(usize, bool)>>,
2478 }
2479
2480 impl CountingCompactor {
2481 fn calls(&self) -> Vec<(usize, bool)> {
2482 self.log.lock().unwrap().clone()
2483 }
2484 }
2485
2486 impl Compactor for CountingCompactor {
2487 type Artifact = TextSummary;
2488
2489 fn compact<'a>(
2490 &'a self,
2491 _conversation_id: &'a str,
2492 evicted: &'a [Message],
2493 carry_over: Option<&'a Self::Artifact>,
2494 ) -> WasmBoxedFuture<'a, Result<Self::Artifact, MemoryError>> {
2495 Box::pin(async move {
2496 self.log
2497 .lock()
2498 .unwrap()
2499 .push((evicted.len(), carry_over.is_some()));
2500 let prev = carry_over.map(|s| s.as_str()).unwrap_or("");
2501 Ok(TextSummary(format!("{prev}|{}", evicted.len())))
2502 })
2503 }
2504 }
2505
2506 #[tokio::test]
2507 async fn compacting_no_demotion_does_not_invoke_compactor() {
2508 let compactor = Arc::new(CountingCompactor::default());
2509 let mem = CompactingMemory::new(
2510 InMemoryConversationMemory::new(),
2511 SlidingWindowMemory::last_messages(10),
2512 compactor.clone(),
2513 );
2514
2515 mem.append("c", vec![user("a"), assistant("b")])
2516 .await
2517 .unwrap();
2518 mem.load("c").await.unwrap();
2519 mem.load("c").await.unwrap();
2520 mem.load("c").await.unwrap();
2521 assert!(compactor.calls().is_empty());
2522 assert_eq!(mem.tracked_conversations(), 0);
2524 }
2525
2526 #[tokio::test]
2527 async fn compacting_invokes_compactor_only_on_new_demotions() {
2528 let compactor = Arc::new(CountingCompactor::default());
2529 let mem = CompactingMemory::new(
2530 InMemoryConversationMemory::new(),
2531 SlidingWindowMemory::last_messages(2),
2532 compactor.clone(),
2533 );
2534
2535 mem.append(
2537 "c",
2538 vec![user("a"), assistant("b"), user("c"), assistant("d")],
2539 )
2540 .await
2541 .unwrap();
2542 mem.load("c").await.unwrap();
2543 mem.load("c").await.unwrap();
2545 mem.load("c").await.unwrap();
2546 let calls = compactor.calls();
2547 assert_eq!(
2548 calls.len(),
2549 1,
2550 "compactor invoked more than once: {calls:?}"
2551 );
2552 assert_eq!(calls[0], (2, false));
2553
2554 mem.append("c", vec![user("e"), assistant("f")])
2557 .await
2558 .unwrap();
2559 mem.load("c").await.unwrap();
2560 mem.load("c").await.unwrap();
2561 let calls = compactor.calls();
2562 assert_eq!(calls.len(), 2, "expected exactly one new call: {calls:?}");
2563 assert_eq!(calls[1], (2, true));
2566 }
2567
2568 #[tokio::test]
2569 async fn compacting_serialises_concurrent_loads() {
2570 let compactor = Arc::new(CountingCompactor::default());
2573 let mem = Arc::new(CompactingMemory::new(
2574 InMemoryConversationMemory::new(),
2575 SlidingWindowMemory::last_messages(2),
2576 compactor.clone(),
2577 ));
2578 mem.append(
2579 "c",
2580 vec![user("a"), assistant("b"), user("c"), assistant("d")],
2581 )
2582 .await
2583 .unwrap();
2584
2585 let mut handles = Vec::new();
2586 for _ in 0..32 {
2587 let mem = mem.clone();
2588 handles.push(tokio::spawn(async move {
2589 mem.load("c").await.unwrap();
2590 }));
2591 }
2592 for h in handles {
2593 h.await.unwrap();
2594 }
2595
2596 let calls = compactor.calls();
2599 assert_eq!(calls.len(), 1, "expected exactly 1 call: {calls:?}");
2600 }
2601
2602 #[tokio::test]
2603 async fn compacting_clear_drops_summary_carry_over() {
2604 let compactor = Arc::new(CountingCompactor::default());
2608 let mem = CompactingMemory::new(
2609 InMemoryConversationMemory::new(),
2610 SlidingWindowMemory::last_messages(1),
2611 compactor.clone(),
2612 );
2613 mem.append("c", vec![user("a"), assistant("b"), user("c")])
2614 .await
2615 .unwrap();
2616 mem.load("c").await.unwrap();
2617 assert_eq!(compactor.calls()[0], (2, false));
2618
2619 mem.clear("c").await.unwrap();
2620 assert_eq!(mem.tracked_conversations(), 0);
2621
2622 mem.append("c", vec![user("x"), assistant("y"), user("z")])
2623 .await
2624 .unwrap();
2625 mem.load("c").await.unwrap();
2626 let calls = compactor.calls();
2627 assert_eq!(calls.len(), 2);
2628 assert_eq!(calls[1], (2, false));
2630 }
2631
2632 #[tokio::test]
2633 async fn compacting_forget_drops_summary() {
2634 let compactor = Arc::new(CountingCompactor::default());
2635 let mem = CompactingMemory::new(
2636 InMemoryConversationMemory::new(),
2637 SlidingWindowMemory::last_messages(1),
2638 compactor.clone(),
2639 );
2640 mem.append("c", vec![user("a"), assistant("b"), user("c")])
2641 .await
2642 .unwrap();
2643 mem.load("c").await.unwrap();
2644 assert_eq!(mem.tracked_conversations(), 1);
2645 mem.forget("c");
2646 assert_eq!(mem.tracked_conversations(), 0);
2647
2648 mem.load("c").await.unwrap();
2651 let calls = compactor.calls();
2652 assert_eq!(calls.len(), 2);
2653 assert_eq!(calls[1], (2, false));
2654 }
2655
2656 #[tokio::test]
2657 async fn compacting_arc_compactor_works() {
2658 let compactor: Arc<dyn Compactor<Artifact = TextSummary>> =
2661 Arc::new(TemplateCompactor::new());
2662 let mem = CompactingMemory::new(
2663 InMemoryConversationMemory::new(),
2664 SlidingWindowMemory::last_messages(1),
2665 compactor,
2666 );
2667 mem.append("c", vec![user("a"), assistant("b"), user("c")])
2668 .await
2669 .unwrap();
2670 let loaded = mem.load("c").await.unwrap();
2671 assert_eq!(loaded.len(), 2);
2672 assert!(matches!(&loaded[0], Message::System { .. }));
2673 }
2674
2675 #[tokio::test]
2676 async fn compacting_into_inner_returns_components() {
2677 let mem = CompactingMemory::new(
2678 InMemoryConversationMemory::new(),
2679 SlidingWindowMemory::last_messages(1),
2680 TemplateCompactor::new(),
2681 );
2682 let (_inner, _policy, _compactor) = mem.into_inner();
2683 }
2684
2685 #[tokio::test]
2686 async fn compacting_isolates_conversations() {
2687 let compactor = Arc::new(CountingCompactor::default());
2688 let mem = CompactingMemory::new(
2689 InMemoryConversationMemory::new(),
2690 SlidingWindowMemory::last_messages(1),
2691 compactor.clone(),
2692 );
2693 mem.append("a", vec![user("a1"), assistant("a2"), user("a3")])
2694 .await
2695 .unwrap();
2696 mem.append("b", vec![user("b1"), assistant("b2"), user("b3")])
2697 .await
2698 .unwrap();
2699
2700 let a = mem.load("a").await.unwrap();
2701 let b = mem.load("b").await.unwrap();
2702 assert_eq!(a.len(), 2);
2704 assert_eq!(b.len(), 2);
2705 assert_eq!(compactor.calls().len(), 2);
2706 assert_eq!(mem.tracked_conversations(), 2);
2707 }
2708
2709 #[tokio::test]
2710 async fn compacting_composes_with_token_window() {
2711 let mem = CompactingMemory::new(
2714 InMemoryConversationMemory::new(),
2715 TokenWindowMemory::new(30, HeuristicTokenCounter::openai()),
2716 TemplateCompactor::new(),
2717 );
2718 mem.append(
2719 "c",
2720 vec![
2721 user("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
2722 assistant("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
2723 user("cccccccccccccccccccc"),
2724 assistant("d"),
2725 ],
2726 )
2727 .await
2728 .unwrap();
2729 let loaded = mem.load("c").await.unwrap();
2730 assert!(loaded.len() >= 2);
2732 assert!(matches!(&loaded[0], Message::System { .. }));
2733 }
2734
2735 #[tokio::test]
2736 async fn template_compactor_renders_system_messages() {
2737 let compactor = TemplateCompactor::new();
2738 let evicted = vec![
2739 Message::System {
2740 content: "you are helpful".into(),
2741 },
2742 user("hi"),
2743 assistant("hello"),
2744 ];
2745 let summary = compactor.compact("c", &evicted, None).await.unwrap();
2746 let s = summary.as_str();
2747 assert!(s.contains("system: you are helpful"), "got: {s}");
2748 assert!(s.contains("user: hi"));
2749 assert!(s.contains("assistant: hello"));
2750 }
2751
2752 #[tokio::test]
2753 async fn template_compactor_renders_tool_call_marker() {
2754 let compactor = TemplateCompactor::new();
2755 let evicted = vec![tool_call_msg(), tool_result_msg()];
2756 let summary = compactor.compact("c", &evicted, None).await.unwrap();
2757 let s = summary.as_str();
2758 assert!(s.contains("[tool call: t]"), "got: {s}");
2759 assert!(s.contains("[tool result]"), "got: {s}");
2760 }
2761
2762 #[tokio::test]
2767 async fn template_compactor_separates_parts_asymmetrically_around_empty_text() {
2768 let compactor = TemplateCompactor::new();
2769 let evicted = vec![Message::User {
2770 content: vec![
2771 UserContent::text(""),
2772 UserContent::text("a"),
2773 UserContent::text(""),
2774 UserContent::text("b"),
2775 ],
2776 }];
2777 let summary = compactor.compact("c", &evicted, None).await.unwrap();
2778 let rendered = summary.as_str();
2779 assert!(
2780 rendered.contains("user: a b"),
2781 "leading empty part adds no separator, interior one adds its own; got: {rendered}"
2782 );
2783 }
2784
2785 #[tokio::test]
2786 async fn template_compactor_carry_over_threaded() {
2787 let compactor = TemplateCompactor::new();
2788 let first = compactor
2789 .compact("c", &[user("hello")], None)
2790 .await
2791 .unwrap();
2792 assert!(!first.as_str().is_empty());
2793
2794 let second = compactor
2795 .compact("c", &[assistant("world")], Some(&first))
2796 .await
2797 .unwrap();
2798 assert!(second.as_str().contains(first.as_str()));
2800 assert!(second.as_str().contains("assistant: world"));
2801 }
2802
2803 #[tokio::test]
2804 async fn template_compactor_artifact_into_message() {
2805 let s = TextSummary("rolled-up text".into());
2806 let msg: Message = s.into();
2807 let Message::System { content } = msg else {
2808 panic!("expected system message");
2809 };
2810 assert_eq!(content, "rolled-up text");
2811 }
2812
2813 #[tokio::test]
2814 async fn template_compactor_caps_summary_at_max_bytes() {
2815 let cap = 256;
2816 let compactor = TemplateCompactor::new().with_max_bytes(cap);
2817 let mut evicted = Vec::new();
2819 for i in 0..50 {
2820 evicted.push(user(&format!("message number {i} with some filler")));
2821 }
2822 let summary = compactor.compact("c", &evicted, None).await.unwrap();
2823 assert!(
2824 summary.as_str().len()
2825 <= cap + "[Conversation summary so far]\n[\u{2026}truncated\u{2026}]\n".len(),
2826 "summary len {} exceeds cap {} (plus header+marker)",
2827 summary.as_str().len(),
2828 cap,
2829 );
2830 assert!(
2832 summary
2833 .as_str()
2834 .starts_with("[Conversation summary so far]\n")
2835 );
2836 assert!(summary.as_str().contains("[\u{2026}truncated\u{2026}]"));
2838 assert!(summary.as_str().contains("message number 49"));
2840 }
2841
2842 #[tokio::test]
2843 async fn template_compactor_unbounded_by_default() {
2844 let compactor = TemplateCompactor::new();
2845 let mut evicted = Vec::new();
2846 for i in 0..200 {
2847 evicted.push(user(&format!("msg {i}")));
2848 }
2849 let summary = compactor.compact("c", &evicted, None).await.unwrap();
2850 assert!(!summary.as_str().contains("[\u{2026}truncated\u{2026}]"));
2852 assert!(summary.as_str().contains("msg 0"));
2854 assert!(summary.as_str().contains("msg 199"));
2855 }
2856
2857 #[tokio::test]
2858 async fn template_compactor_with_max_bytes_zero_is_unbounded() {
2859 let compactor = TemplateCompactor::new().with_max_bytes(0);
2860 let mut evicted = Vec::new();
2861 for i in 0..200 {
2862 evicted.push(user(&format!("msg {i}")));
2863 }
2864 let summary = compactor.compact("c", &evicted, None).await.unwrap();
2865 assert!(!summary.as_str().contains("[\u{2026}truncated\u{2026}]"));
2866 }
2867
2868 #[tokio::test]
2869 async fn compacting_summary_stays_bounded_across_rolls() {
2870 let cap = 512;
2873 let mem = CompactingMemory::new(
2874 InMemoryConversationMemory::new(),
2875 SlidingWindowMemory::last_messages(2),
2876 TemplateCompactor::new().with_max_bytes(cap),
2877 );
2878 mem.append("c", vec![user("seed-a"), assistant("seed-b")])
2879 .await
2880 .unwrap();
2881 for i in 0..30 {
2882 mem.append(
2883 "c",
2884 vec![
2885 user(&format!("user line {i} ----- padding padding padding")),
2886 assistant(&format!("assistant line {i} ----- padding padding")),
2887 ],
2888 )
2889 .await
2890 .unwrap();
2891 mem.load("c").await.unwrap();
2892 }
2893 let loaded = mem.load("c").await.unwrap();
2894 let Message::System { content } = &loaded[0] else {
2895 panic!("expected summary");
2896 };
2897 let slack = "[Conversation summary so far]\n[\u{2026}truncated\u{2026}]\n".len();
2899 assert!(
2900 content.len() <= cap + slack,
2901 "summary grew to {} bytes (cap {}, slack {})",
2902 content.len(),
2903 cap,
2904 slack,
2905 );
2906 }
2907
2908 #[tokio::test]
2909 async fn compacting_concurrent_with_clear_does_not_resurrect_state() {
2910 use std::sync::atomic::{AtomicBool, Ordering};
2913
2914 struct GatedCompactor {
2915 release: tokio::sync::Notify,
2916 entered: AtomicBool,
2917 }
2918
2919 impl Compactor for GatedCompactor {
2920 type Artifact = TextSummary;
2921
2922 fn compact<'a>(
2923 &'a self,
2924 _conversation_id: &'a str,
2925 _evicted: &'a [Message],
2926 _carry_over: Option<&'a Self::Artifact>,
2927 ) -> WasmBoxedFuture<'a, Result<Self::Artifact, MemoryError>> {
2928 Box::pin(async move {
2929 self.entered.store(true, Ordering::SeqCst);
2930 self.release.notified().await;
2931 Ok(TextSummary("late summary".into()))
2932 })
2933 }
2934 }
2935
2936 let compactor = Arc::new(GatedCompactor {
2937 release: tokio::sync::Notify::new(),
2938 entered: AtomicBool::new(false),
2939 });
2940 let mem = Arc::new(CompactingMemory::new(
2941 InMemoryConversationMemory::new(),
2942 SlidingWindowMemory::last_messages(1),
2943 compactor.clone(),
2944 ));
2945 mem.append("c", vec![user("a"), assistant("b"), user("c")])
2946 .await
2947 .unwrap();
2948
2949 let mem_load = mem.clone();
2951 let load_handle = tokio::spawn(async move { mem_load.load("c").await });
2952
2953 while !compactor.entered.load(Ordering::SeqCst) {
2955 tokio::task::yield_now().await;
2956 }
2957
2958 mem.clear("c").await.unwrap();
2960
2961 compactor.release.notify_one();
2964 let _ = load_handle.await.unwrap();
2965
2966 assert_eq!(mem.tracked_conversations(), 0);
2967 assert!(mem.load("c").await.unwrap().is_empty());
2969 }
2970
2971 #[tokio::test]
2972 async fn compacting_dropped_load_releases_in_flight_gate() {
2973 use std::sync::atomic::{AtomicUsize, Ordering};
2977
2978 struct GatedCompactor {
2979 release: tokio::sync::Notify,
2980 entered: AtomicUsize,
2981 }
2982
2983 impl Compactor for GatedCompactor {
2984 type Artifact = TextSummary;
2985
2986 fn compact<'a>(
2987 &'a self,
2988 _conversation_id: &'a str,
2989 _evicted: &'a [Message],
2990 _carry_over: Option<&'a Self::Artifact>,
2991 ) -> WasmBoxedFuture<'a, Result<Self::Artifact, MemoryError>> {
2992 Box::pin(async move {
2993 self.entered.fetch_add(1, Ordering::SeqCst);
2994 self.release.notified().await;
2995 Ok(TextSummary("ran".into()))
2996 })
2997 }
2998 }
2999
3000 let compactor = Arc::new(GatedCompactor {
3001 release: tokio::sync::Notify::new(),
3002 entered: AtomicUsize::new(0),
3003 });
3004 let mem = Arc::new(CompactingMemory::new(
3005 InMemoryConversationMemory::new(),
3006 SlidingWindowMemory::last_messages(1),
3007 compactor.clone(),
3008 ));
3009 mem.append("c", vec![user("a"), assistant("b"), user("c")])
3010 .await
3011 .unwrap();
3012
3013 let mem_load = mem.clone();
3017 let handle = tokio::spawn(async move { mem_load.load("c").await });
3018 while compactor.entered.load(Ordering::SeqCst) == 0 {
3019 tokio::task::yield_now().await;
3020 }
3021 handle.abort();
3022 let _ = handle.await;
3023
3024 let mem_load = mem.clone();
3029 let retry = tokio::spawn(async move { mem_load.load("c").await });
3030 while compactor.entered.load(Ordering::SeqCst) < 2 {
3034 tokio::task::yield_now().await;
3035 }
3036 compactor.release.notify_one();
3037 let loaded = retry.await.unwrap().unwrap();
3038 assert_eq!(loaded.len(), 2);
3039 let Message::System { content } = &loaded[0] else {
3040 panic!("expected summary")
3041 };
3042 assert_eq!(content, "ran");
3043 }
3044
3045 #[tokio::test]
3046 async fn compacting_stale_cancelled_load_does_not_clear_new_reservation() {
3047 use std::sync::atomic::{AtomicUsize, Ordering};
3048
3049 struct GatedCompactor {
3050 release: tokio::sync::Notify,
3051 rendezvous: tokio::sync::Notify,
3052 entered: AtomicUsize,
3053 }
3054
3055 impl Compactor for GatedCompactor {
3056 type Artifact = TextSummary;
3057
3058 fn compact<'a>(
3059 &'a self,
3060 _conversation_id: &'a str,
3061 _evicted: &'a [Message],
3062 _carry_over: Option<&'a Self::Artifact>,
3063 ) -> WasmBoxedFuture<'a, Result<Self::Artifact, MemoryError>> {
3064 Box::pin(async move {
3065 self.entered.fetch_add(1, Ordering::SeqCst);
3066 self.rendezvous.notify_one();
3067 self.release.notified().await;
3068 Ok(TextSummary("ran".into()))
3069 })
3070 }
3071 }
3072
3073 let compactor = Arc::new(GatedCompactor {
3074 release: tokio::sync::Notify::new(),
3075 rendezvous: tokio::sync::Notify::new(),
3076 entered: AtomicUsize::new(0),
3077 });
3078 let mem = Arc::new(CompactingMemory::new(
3079 InMemoryConversationMemory::new(),
3080 SlidingWindowMemory::last_messages(1),
3081 compactor.clone(),
3082 ));
3083
3084 mem.append("c", vec![user("old 1"), assistant("old 2"), user("old 3")])
3085 .await
3086 .unwrap();
3087
3088 let mem_load = mem.clone();
3089 let stale = tokio::spawn(async move { mem_load.load("c").await });
3090 compactor.rendezvous.notified().await;
3091 assert_eq!(compactor.entered.load(Ordering::SeqCst), 1);
3092
3093 mem.clear("c").await.unwrap();
3094 mem.append(
3095 "c",
3096 vec![user("fresh 1"), assistant("fresh 2"), user("fresh 3")],
3097 )
3098 .await
3099 .unwrap();
3100
3101 let mem_load = mem.clone();
3102 let fresh = tokio::spawn(async move { mem_load.load("c").await });
3103 compactor.rendezvous.notified().await;
3104 assert_eq!(compactor.entered.load(Ordering::SeqCst), 2);
3105
3106 stale.abort();
3107 let _ = stale.await;
3108
3109 let mem_load = mem.clone();
3110 let mut concurrent = tokio::spawn(async move { mem_load.load("c").await });
3111 let concurrent_kept = tokio::select! {
3112 result = &mut concurrent => result.unwrap().unwrap(),
3113 _ = compactor.rendezvous.notified() => {
3114 panic!("stale guard must not clear the fresh in-flight reservation")
3115 }
3116 };
3117 assert_eq!(
3118 compactor.entered.load(Ordering::SeqCst),
3119 2,
3120 "stale guard must not clear the fresh in-flight reservation"
3121 );
3122
3123 compactor.release.notify_one();
3124 assert_eq!(fresh.await.unwrap().unwrap().len(), 2);
3125 assert_eq!(concurrent_kept.len(), 1);
3126 assert_eq!(compactor.entered.load(Ordering::SeqCst), 2);
3127 }
3128
3129 #[tokio::test]
3130 async fn template_compactor_caps_summary_with_multiline_header() {
3131 let cap = 256;
3136 let compactor = TemplateCompactor::with_header("line one\nline two").with_max_bytes(cap);
3137 let mut evicted = Vec::new();
3138 for i in 0..50 {
3139 evicted.push(user(&format!("message number {i} with some filler")));
3140 }
3141 let summary = compactor.compact("c", &evicted, None).await.unwrap();
3142 let text = summary.as_str();
3143
3144 assert!(text.starts_with("line one\n"));
3146 assert!(text.contains("[\u{2026}truncated\u{2026}]"));
3148 assert!(text.contains("message number 49"));
3149 let overhead = "line one\n".len() + "[\u{2026}truncated\u{2026}]\n".len();
3151 assert!(
3152 text.len() <= cap + overhead,
3153 "summary len {} exceeds cap {} plus overhead {}",
3154 text.len(),
3155 cap,
3156 overhead,
3157 );
3158 }
3159}