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::{
23 borrow::Cow,
24 collections::HashMap,
25 sync::{Arc, Mutex as StdMutex},
26};
27
28pub use rig_core::memory::{
31 Compactor, ConversationMemory, DemotionHook, InMemoryConversationMemory, MemoryError,
32 NoopDemotionHook,
33};
34
35use rig_core::completion::Message;
36use rig_core::id::ConversationId;
37use rig_core::message::UserContent;
38use rig_core::wasm_compat::{WasmBoxedFuture, WasmCompatSend, WasmCompatSync};
39
40pub trait MemoryPolicy: WasmCompatSend + WasmCompatSync {
46 fn apply(&self, messages: Vec<Message>) -> Result<Vec<Message>, MemoryError>;
48
49 fn apply_with_demoted(
63 &self,
64 messages: Vec<Message>,
65 ) -> Result<(Vec<Message>, Vec<Message>), MemoryError> {
66 Ok((self.apply(messages)?, Vec::new()))
67 }
68}
69
70impl<P> MemoryPolicy for Arc<P>
71where
72 P: MemoryPolicy + ?Sized,
73{
74 fn apply(&self, messages: Vec<Message>) -> Result<Vec<Message>, MemoryError> {
75 (**self).apply(messages)
76 }
77
78 fn apply_with_demoted(
79 &self,
80 messages: Vec<Message>,
81 ) -> Result<(Vec<Message>, Vec<Message>), MemoryError> {
82 (**self).apply_with_demoted(messages)
83 }
84}
85
86impl<P> MemoryPolicy for Box<P>
87where
88 P: MemoryPolicy + ?Sized,
89{
90 fn apply(&self, messages: Vec<Message>) -> Result<Vec<Message>, MemoryError> {
91 (**self).apply(messages)
92 }
93
94 fn apply_with_demoted(
95 &self,
96 messages: Vec<Message>,
97 ) -> Result<(Vec<Message>, Vec<Message>), MemoryError> {
98 (**self).apply_with_demoted(messages)
99 }
100}
101
102pub trait IntoFilter: MemoryPolicy + Sized + 'static {
109 fn into_filter(self) -> BoxedFilter {
113 let policy = Arc::new(self);
114 Box::new(move |msgs| {
115 let fallback = msgs.clone();
119 match policy.apply(msgs) {
120 Ok(out) => out,
121 Err(err) => {
122 tracing::warn!(error = %err, "memory policy failed; returning unfiltered history");
123 fallback
124 }
125 }
126 })
127 }
128}
129
130#[cfg(not(target_family = "wasm"))]
132pub type BoxedFilter = Box<dyn Fn(Vec<Message>) -> Vec<Message> + Send + Sync>;
133
134#[cfg(target_family = "wasm")]
136pub type BoxedFilter = Box<dyn Fn(Vec<Message>) -> Vec<Message>>;
137
138impl<P> IntoFilter for P where P: MemoryPolicy + 'static {}
139
140#[derive(Debug, Default, Clone, Copy)]
142pub struct NoopMemoryPolicy;
143
144impl MemoryPolicy for NoopMemoryPolicy {
145 fn apply(&self, messages: Vec<Message>) -> Result<Vec<Message>, MemoryError> {
146 Ok(messages)
147 }
148}
149
150#[derive(Debug, Clone, Copy)]
158pub struct SlidingWindowMemory {
159 max_messages: usize,
160}
161
162impl SlidingWindowMemory {
163 pub fn last_messages(n: usize) -> Self {
165 Self { max_messages: n }
166 }
167}
168
169impl MemoryPolicy for SlidingWindowMemory {
170 fn apply(&self, messages: Vec<Message>) -> Result<Vec<Message>, MemoryError> {
171 Ok(self.apply_with_demoted(messages)?.0)
172 }
173
174 fn apply_with_demoted(
175 &self,
176 messages: Vec<Message>,
177 ) -> Result<(Vec<Message>, Vec<Message>), MemoryError> {
178 if messages.len() <= self.max_messages {
179 return Ok((messages, Vec::new()));
180 }
181
182 let keep_from = messages.len() - self.max_messages;
183 Ok(split_window(messages, keep_from))
184 }
185}
186
187fn split_window(messages: Vec<Message>, keep_from: usize) -> (Vec<Message>, Vec<Message>) {
195 let mut iter = messages.into_iter();
196 let mut demoted: Vec<Message> = (&mut iter).take(keep_from).collect();
197 let mut window: Vec<Message> = iter.collect();
198
199 let mut orphan_prefix_len = 0;
200 for (index, message) in window.iter().enumerate() {
201 match message {
202 Message::Assistant(_) => break,
203 Message::User { content }
204 if content
205 .iter()
206 .any(|item| matches!(item, UserContent::ToolResult(_))) =>
207 {
208 orphan_prefix_len = index + 1;
209 }
210 _ => {}
211 }
212 }
213 demoted.extend(window.drain(..orphan_prefix_len));
214
215 (window, demoted)
216}
217
218pub trait TokenCounter: WasmCompatSend + WasmCompatSync {
224 fn count(&self, message: &Message) -> usize;
226}
227
228impl<F> TokenCounter for F
229where
230 F: Fn(&Message) -> usize + WasmCompatSend + WasmCompatSync,
231{
232 fn count(&self, message: &Message) -> usize {
233 (self)(message)
234 }
235}
236
237impl<C> TokenCounter for Arc<C>
238where
239 C: TokenCounter + ?Sized,
240{
241 fn count(&self, message: &Message) -> usize {
242 (**self).count(message)
243 }
244}
245
246impl TokenCounter for Box<dyn TokenCounter> {
247 fn count(&self, message: &Message) -> usize {
248 (**self).count(message)
249 }
250}
251
252#[derive(Debug, Clone, Copy)]
263pub struct HeuristicTokenCounter {
264 bytes_per_token: f32,
265 per_message_overhead: usize,
266 per_attachment_tokens: usize,
267}
268
269impl HeuristicTokenCounter {
270 pub fn new(
275 bytes_per_token: f32,
276 per_message_overhead: usize,
277 per_attachment_tokens: usize,
278 ) -> Self {
279 let bytes_per_token = if bytes_per_token.is_finite() && bytes_per_token >= 1.0 {
280 bytes_per_token
281 } else {
282 1.0
283 };
284 Self {
285 bytes_per_token,
286 per_message_overhead,
287 per_attachment_tokens,
288 }
289 }
290
291 pub fn anthropic() -> Self {
293 Self::new(3.5, 4, 256)
294 }
295
296 fn bytes_to_tokens(&self, bytes: usize) -> usize {
297 let tokens = (bytes as f32) / self.bytes_per_token;
301 tokens.ceil() as usize
302 }
303
304 fn count_user(&self, content: &rig_core::message::UserContent) -> usize {
305 use rig_core::message::UserContent;
306 match content {
307 UserContent::Text(text) => self.bytes_to_tokens(text.text.len()),
308 UserContent::ToolResult(result) => result
309 .content
310 .iter()
311 .map(|c| match c {
312 rig_core::message::ToolResultContent::Text(t) => {
313 self.bytes_to_tokens(t.text.len())
314 }
315 rig_core::message::ToolResultContent::Json { value } => {
316 self.bytes_to_tokens(value.to_string().len())
317 }
318 rig_core::message::ToolResultContent::Image(_) => self.per_attachment_tokens,
319 })
320 .sum(),
321 UserContent::Image(_)
322 | UserContent::Audio(_)
323 | UserContent::Video(_)
324 | UserContent::Document(_) => self.per_attachment_tokens,
325 }
326 }
327
328 fn count_assistant(&self, content: &rig_core::message::AssistantContent) -> usize {
329 use rig_core::message::AssistantContent;
330 match content {
331 AssistantContent::Text(text) => self.bytes_to_tokens(text.text.len()),
332 AssistantContent::Reasoning(reasoning) => self.bytes_to_tokens(reasoning.text.len()),
333 AssistantContent::ToolCall(call) => {
334 let name_bytes = call.function.name.len();
335 let args_bytes = call.function.arguments_value().to_string().len();
336 self.bytes_to_tokens(name_bytes + args_bytes)
337 }
338 AssistantContent::Image(_) => self.per_attachment_tokens,
339 AssistantContent::Opaque(opaque) => self.bytes_to_tokens(opaque.item.to_string().len()),
341 other => self.bytes_to_tokens(
342 rig_core::serde_json::to_string(other).map_or(0, |json| json.len()),
343 ),
344 }
345 }
346}
347
348impl Default for HeuristicTokenCounter {
349 fn default() -> Self {
351 Self::new(4.0, 4, 256)
352 }
353}
354
355impl TokenCounter for HeuristicTokenCounter {
356 fn count(&self, message: &Message) -> usize {
357 let content_tokens: usize = match message {
358 Message::User { content } => content.iter().map(|c| self.count_user(c)).sum(),
359 Message::Assistant(turn) => turn.content.iter().map(|c| self.count_assistant(c)).sum(),
360 Message::System { content } => self.bytes_to_tokens(content.len()),
361 };
362 content_tokens.saturating_add(self.per_message_overhead)
363 }
364}
365
366pub struct TokenWindowMemory {
375 max_tokens: usize,
376 counter: Arc<dyn TokenCounter>,
377}
378
379impl TokenWindowMemory {
380 pub fn new<C>(max_tokens: usize, counter: C) -> Self
382 where
383 C: TokenCounter + 'static,
384 {
385 Self {
386 max_tokens,
387 counter: Arc::new(counter),
388 }
389 }
390}
391
392impl std::fmt::Debug for TokenWindowMemory {
393 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
394 f.debug_struct("TokenWindowMemory")
395 .field("max_tokens", &self.max_tokens)
396 .field("counter", &"<counter>")
397 .finish()
398 }
399}
400
401impl MemoryPolicy for TokenWindowMemory {
402 fn apply(&self, messages: Vec<Message>) -> Result<Vec<Message>, MemoryError> {
403 Ok(self.apply_with_demoted(messages)?.0)
404 }
405
406 fn apply_with_demoted(
407 &self,
408 messages: Vec<Message>,
409 ) -> Result<(Vec<Message>, Vec<Message>), MemoryError> {
410 let mut budget = self.max_tokens;
411 let mut keep_from = messages.len();
412
413 for (idx, msg) in messages.iter().enumerate().rev() {
414 let cost = self.counter.count(msg);
415 if cost > budget {
416 break;
417 }
418 budget -= cost;
419 keep_from = idx;
420 }
421
422 Ok(split_window(messages, keep_from))
423 }
424}
425
426#[derive(Debug, Clone, Copy)]
440pub struct PolicyMemory<M, P> {
441 inner: M,
442 policy: P,
443}
444
445impl<M, P> PolicyMemory<M, P> {
446 pub fn new(inner: M, policy: P) -> Self {
448 Self { inner, policy }
449 }
450
451 pub fn inner(&self) -> &M {
453 &self.inner
454 }
455
456 pub fn policy(&self) -> &P {
458 &self.policy
459 }
460
461 pub fn into_inner(self) -> (M, P) {
463 (self.inner, self.policy)
464 }
465}
466
467impl<M, P> ConversationMemory for PolicyMemory<M, P>
468where
469 M: ConversationMemory,
470 P: MemoryPolicy,
471{
472 fn load<'a>(
473 &'a self,
474 conversation_id: &'a ConversationId,
475 ) -> WasmBoxedFuture<'a, Result<Vec<Message>, MemoryError>> {
476 Box::pin(async move {
477 let messages = self.inner.load(conversation_id).await?;
478 self.policy.apply(messages)
479 })
480 }
481
482 fn append<'a>(
483 &'a self,
484 conversation_id: &'a ConversationId,
485 messages: Vec<Message>,
486 ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
487 self.inner.append(conversation_id, messages)
488 }
489
490 fn clear<'a>(
491 &'a self,
492 conversation_id: &'a ConversationId,
493 ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
494 self.inner.clear(conversation_id)
495 }
496}
497
498pub struct DemotingPolicyMemory<M, P, H> {
524 inner: M,
525 policy: P,
526 hook: H,
527 state: StdMutex<HashMap<ConversationId, ConversationDemotionState>>,
528}
529
530type InFlightReservation = Arc<()>;
531
532macro_rules! stateful_wrapper_common {
537 ($ty:ident, $third:ident: $tgen:ident $(: $tbound:ident)?) => {
538 impl<M, P, $tgen $(: $tbound)?> $ty<M, P, $tgen> {
539 pub fn inner(&self) -> &M {
541 &self.inner
542 }
543
544 pub fn policy(&self) -> &P {
546 &self.policy
547 }
548
549 pub fn $third(&self) -> &$tgen {
551 &self.$third
552 }
553
554 pub fn into_inner(self) -> (M, P, $tgen) {
556 (self.inner, self.policy, self.$third)
557 }
558
559 pub fn forget(&self, conversation_id: &ConversationId) {
564 if let Ok(mut guard) = self.state.lock() {
565 guard.remove(conversation_id);
566 }
567 }
568
569 pub fn tracked_conversations(&self) -> usize {
573 self.state.lock().map_or(0, |g| g.len())
574 }
575 }
576
577 impl<M, P, $tgen $(: $tbound)?> std::fmt::Debug for $ty<M, P, $tgen>
578 where
579 M: std::fmt::Debug,
580 P: std::fmt::Debug,
581 {
582 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
583 f.debug_struct(stringify!($ty))
584 .field("inner", &self.inner)
585 .field("policy", &self.policy)
586 .field(
587 stringify!($third),
588 &concat!("<", stringify!($third), ">"),
589 )
590 .finish()
591 }
592 }
593 };
594}
595
596macro_rules! stateful_wrapper_append_clear {
599 () => {
600 fn append<'a>(
601 &'a self,
602 conversation_id: &'a ConversationId,
603 messages: Vec<Message>,
604 ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
605 self.inner.append(conversation_id, messages)
606 }
607
608 fn clear<'a>(
609 &'a self,
610 conversation_id: &'a ConversationId,
611 ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
612 Box::pin(async move {
613 self.inner.clear(conversation_id).await?;
614 self.forget(conversation_id);
615 Ok(())
616 })
617 }
618 };
619}
620
621#[derive(Debug, Default, Clone)]
622struct ConversationDemotionState {
623 delivered: usize,
626 in_flight: Option<InFlightReservation>,
630}
631
632impl<M, P, H> DemotingPolicyMemory<M, P, H> {
633 pub fn new(inner: M, policy: P, hook: H) -> Self {
636 Self {
637 inner,
638 policy,
639 hook,
640 state: StdMutex::new(HashMap::new()),
641 }
642 }
643}
644
645stateful_wrapper_common!(DemotingPolicyMemory, hook: H);
646
647impl<M, P, H> ConversationMemory for DemotingPolicyMemory<M, P, H>
648where
649 M: ConversationMemory,
650 P: MemoryPolicy,
651 H: DemotionHook,
652{
653 fn load<'a>(
654 &'a self,
655 conversation_id: &'a ConversationId,
656 ) -> WasmBoxedFuture<'a, Result<Vec<Message>, MemoryError>> {
657 Box::pin(async move {
658 let messages = self.inner.load(conversation_id).await?;
659 let (kept, mut demoted) = self.policy.apply_with_demoted(messages)?;
660 let demoted_count = demoted.len();
661
662 let (pending, reservation) = {
665 let mut guard = self.state.lock().map_err(poisoned)?;
666 if let Some(entry) = guard.get_mut(conversation_id) {
667 if entry.in_flight.is_some() {
668 return Ok(kept);
672 }
673 if entry.delivered >= demoted_count {
674 (Vec::new(), None)
675 } else {
676 let split = entry.delivered;
677 let reservation = Arc::new(());
678 entry.in_flight = Some(reservation.clone());
679 (demoted.split_off(split), Some(reservation))
680 }
681 } else if demoted_count == 0 {
682 (Vec::new(), None)
683 } else {
684 let reservation = Arc::new(());
685 guard.insert(
686 conversation_id.clone(),
687 ConversationDemotionState {
688 delivered: 0,
689 in_flight: Some(reservation.clone()),
690 },
691 );
692 (std::mem::take(&mut demoted), Some(reservation))
693 }
694 };
695
696 let Some(reservation) = reservation else {
697 return Ok(kept);
698 };
699
700 let in_flight_guard =
706 InFlightGuard::new(&self.state, conversation_id, reservation.clone());
707
708 let result = self.hook.on_demote(conversation_id, pending).await;
709
710 release_in_flight(&self.state, conversation_id, &reservation, |entry| {
713 if result.is_ok() {
714 entry.delivered = demoted_count;
715 }
716 })?;
717 in_flight_guard.disarm();
718 result?;
719 Ok(kept)
720 })
721 }
722
723 stateful_wrapper_append_clear!();
724}
725
726fn poisoned<E: std::fmt::Display>(err: E) -> MemoryError {
727 MemoryError::Internal(err.to_string())
728}
729
730fn release_in_flight<S: InFlightSlot, T>(
734 state: &StdMutex<HashMap<ConversationId, S>>,
735 key: &ConversationId,
736 reservation: &InFlightReservation,
737 on_match: impl FnOnce(&mut S) -> T,
738) -> Result<Option<T>, MemoryError> {
739 let mut guard = state.lock().map_err(poisoned)?;
740 let Some(entry) = guard.get_mut(key) else {
741 return Ok(None);
742 };
743 if entry
744 .in_flight_mut()
745 .as_ref()
746 .is_some_and(|current| Arc::ptr_eq(current, reservation))
747 {
748 *entry.in_flight_mut() = None;
749 return Ok(Some(on_match(entry)));
750 }
751 Ok(None)
752}
753
754trait InFlightSlot {
758 fn in_flight_mut(&mut self) -> &mut Option<InFlightReservation>;
759}
760
761impl InFlightSlot for ConversationDemotionState {
762 fn in_flight_mut(&mut self) -> &mut Option<InFlightReservation> {
763 &mut self.in_flight
764 }
765}
766
767impl<A> InFlightSlot for ConversationCompactionState<A> {
768 fn in_flight_mut(&mut self) -> &mut Option<InFlightReservation> {
769 &mut self.in_flight
770 }
771}
772
773struct InFlightGuard<'a, S: InFlightSlot> {
777 state: &'a StdMutex<HashMap<ConversationId, S>>,
778 key: &'a ConversationId,
779 reservation: InFlightReservation,
780 armed: bool,
781}
782
783impl<'a, S: InFlightSlot> InFlightGuard<'a, S> {
784 fn new(
785 state: &'a StdMutex<HashMap<ConversationId, S>>,
786 key: &'a ConversationId,
787 reservation: InFlightReservation,
788 ) -> Self {
789 Self {
790 state,
791 key,
792 reservation,
793 armed: true,
794 }
795 }
796
797 fn disarm(mut self) {
800 self.armed = false;
801 }
802}
803
804impl<S: InFlightSlot> Drop for InFlightGuard<'_, S> {
805 fn drop(&mut self) {
806 if !self.armed {
807 return;
808 }
809 let _ = release_in_flight(self.state, self.key, &self.reservation, |_| ());
811 }
812}
813
814pub struct CompactingMemory<M, P, C: Compactor> {
840 inner: M,
841 policy: P,
842 compactor: C,
843 state: StdMutex<HashMap<ConversationId, ConversationCompactionState<C::Artifact>>>,
844}
845
846struct ConversationCompactionState<A> {
847 summary: Option<A>,
850 absorbed: usize,
853 in_flight: Option<InFlightReservation>,
857}
858
859impl<M, P, C: Compactor> CompactingMemory<M, P, C> {
860 pub fn new(inner: M, policy: P, compactor: C) -> Self {
863 Self {
864 inner,
865 policy,
866 compactor,
867 state: StdMutex::new(HashMap::new()),
868 }
869 }
870}
871
872stateful_wrapper_common!(CompactingMemory, compactor: C: Compactor);
873
874impl<M, P, C> ConversationMemory for CompactingMemory<M, P, C>
875where
876 M: ConversationMemory,
877 P: MemoryPolicy,
878 C: Compactor,
879{
880 fn load<'a>(
881 &'a self,
882 conversation_id: &'a ConversationId,
883 ) -> WasmBoxedFuture<'a, Result<Vec<Message>, MemoryError>> {
884 Box::pin(async move {
885 let messages = self.inner.load(conversation_id).await?;
886 let (kept, demoted) = self.policy.apply_with_demoted(messages)?;
887 let demoted_count = demoted.len();
888
889 let plan = {
892 let mut guard = self.state.lock().map_err(poisoned)?;
893 if let Some(entry) = guard.get_mut(conversation_id) {
894 if entry.in_flight.is_some() {
895 return Ok(splice(entry.summary.clone(), kept));
899 }
900 if demoted_count <= entry.absorbed {
901 return Ok(splice(entry.summary.clone(), kept));
902 }
903 let reservation = Arc::new(());
904 entry.in_flight = Some(reservation.clone());
905 CompactionPlan {
906 carry_over: entry.summary.clone(),
907 skip: entry.absorbed,
908 reservation,
909 }
910 } else if demoted_count == 0 {
911 return Ok(kept);
912 } else {
913 let reservation = Arc::new(());
914 guard.insert(
915 conversation_id.clone(),
916 ConversationCompactionState {
917 summary: None,
918 absorbed: 0,
919 in_flight: Some(reservation.clone()),
920 },
921 );
922 CompactionPlan {
923 carry_over: None,
924 skip: 0,
925 reservation,
926 }
927 }
928 };
929
930 let CompactionPlan {
932 carry_over,
933 skip,
934 reservation,
935 } = plan;
936
937 let in_flight_guard =
943 InFlightGuard::new(&self.state, conversation_id, reservation.clone());
944
945 let Some(new_slice) = demoted.get(skip..) else {
946 drop(in_flight_guard);
949 return Err(MemoryError::Internal(
950 "compaction watermark exceeds demoted slice length".into(),
951 ));
952 };
953
954 let result = self
955 .compactor
956 .compact(conversation_id, new_slice, carry_over.as_ref())
957 .await;
958
959 let summary_for_splice = match result {
962 Ok(artifact) => {
963 release_in_flight(&self.state, conversation_id, &reservation, |entry| {
964 entry.absorbed = demoted_count;
965 entry.summary = Some(artifact.clone());
966 artifact
967 })?
968 }
969 Err(err) => {
970 release_in_flight(&self.state, conversation_id, &reservation, |_| ())?;
971 return Err(err);
972 }
973 };
974
975 in_flight_guard.disarm();
977
978 Ok(splice(summary_for_splice, kept))
979 })
980 }
981
982 stateful_wrapper_append_clear!();
983}
984
985struct CompactionPlan<A> {
986 carry_over: Option<A>,
987 skip: usize,
988 reservation: InFlightReservation,
989}
990
991fn splice<A>(summary: Option<A>, kept: Vec<Message>) -> Vec<Message>
992where
993 A: Into<Message>,
994{
995 match summary {
996 Some(artifact) => {
997 let mut out = Vec::with_capacity(kept.len() + 1);
998 out.push(artifact.into());
999 out.extend(kept);
1000 out
1001 }
1002 None => kept,
1003 }
1004}
1005
1006#[derive(Debug, Clone)]
1025pub struct TemplateCompactor {
1026 header: String,
1027 max_bytes: Option<usize>,
1028}
1029
1030impl TemplateCompactor {
1031 pub fn new() -> Self {
1034 Self::with_header("[Conversation summary so far]")
1035 }
1036
1037 pub fn with_header(header: impl Into<String>) -> Self {
1040 Self {
1041 header: header.into(),
1042 max_bytes: None,
1043 }
1044 }
1045
1046 #[must_use = "the setting applies to the returned value"]
1055 pub fn with_max_bytes(mut self, max_bytes: usize) -> Self {
1056 self.max_bytes = if max_bytes == 0 {
1057 None
1058 } else {
1059 Some(max_bytes)
1060 };
1061 self
1062 }
1063}
1064
1065impl Default for TemplateCompactor {
1066 fn default() -> Self {
1067 Self::new()
1068 }
1069}
1070
1071#[derive(Debug, Clone)]
1078pub struct TextSummary(String);
1079
1080impl TextSummary {
1081 pub fn as_str(&self) -> &str {
1083 &self.0
1084 }
1085
1086 pub fn into_string(self) -> String {
1088 self.0
1089 }
1090}
1091
1092impl From<TextSummary> for Message {
1093 fn from(value: TextSummary) -> Self {
1094 Message::System { content: value.0 }
1095 }
1096}
1097
1098impl Compactor for TemplateCompactor {
1099 type Artifact = TextSummary;
1100
1101 fn compact<'a>(
1102 &'a self,
1103 _conversation_id: &'a ConversationId,
1104 evicted: &'a [Message],
1105 carry_over: Option<&'a Self::Artifact>,
1106 ) -> WasmBoxedFuture<'a, Result<Self::Artifact, MemoryError>> {
1107 Box::pin(async move {
1108 let mut buf = String::new();
1109 buf.push_str(&self.header);
1110 buf.push('\n');
1111 if let Some(prev) = carry_over {
1112 buf.push_str(prev.as_str());
1113 buf.push('\n');
1114 }
1115 for msg in evicted {
1116 let line = render_message_line(msg);
1117 if !line.is_empty() {
1118 buf.push_str(&line);
1119 buf.push('\n');
1120 }
1121 }
1122 if let Some(cap) = self.max_bytes
1123 && buf.len() > cap
1124 {
1125 buf = truncate_summary(&buf, cap);
1126 }
1127 Ok(TextSummary(buf))
1128 })
1129 }
1130}
1131
1132fn truncate_summary(buf: &str, cap: usize) -> String {
1140 const MARKER: &str = "[\u{2026}truncated\u{2026}]\n";
1141 let Some(newline) = buf.find('\n') else {
1142 return buf.to_string();
1143 };
1144 let header_prefix_len = newline + 1;
1145 if buf.len() <= header_prefix_len {
1146 return buf.to_string();
1147 }
1148 let preserved = header_prefix_len + MARKER.len();
1149 let keep_bytes = cap.saturating_sub(preserved);
1150 let body_start = header_prefix_len;
1151 let Some(body) = buf.get(body_start..) else {
1152 return buf.to_string();
1153 };
1154 let mut cut = body.len().saturating_sub(keep_bytes);
1155 while cut < body.len() && !body.is_char_boundary(cut) {
1156 cut += 1;
1157 }
1158 let suffix: &str = body.get(cut..).unwrap_or_default();
1159 let Some(header_with_nl) = buf.get(..header_prefix_len) else {
1160 return buf.to_string();
1161 };
1162 let mut out = String::with_capacity(header_prefix_len + MARKER.len() + suffix.len());
1163 out.push_str(header_with_nl);
1164 out.push_str(MARKER);
1165 out.push_str(suffix);
1166 out
1167}
1168
1169fn render_message_line(msg: &Message) -> String {
1175 use rig_core::message::AssistantContent;
1176
1177 let (role, text) = match msg {
1178 Message::System { content } => ("system", content.clone()),
1179 Message::User { content } => (
1180 "user",
1181 join_parts(content.iter().map(|item| match item {
1182 UserContent::Text(text) => Cow::Borrowed(text.text.as_str()),
1183 UserContent::ToolResult(_) => Cow::Borrowed("[tool result]"),
1184 _ => Cow::Borrowed("[attachment]"),
1185 })),
1186 ),
1187 Message::Assistant(turn) => (
1188 "assistant",
1189 join_parts(turn.content.iter().map(|item| match item {
1190 AssistantContent::Text(text) => Cow::Borrowed(text.text.as_str()),
1191 AssistantContent::ToolCall(call) => {
1192 Cow::Owned(format!("[tool call: {}]", call.function.name))
1193 }
1194 _ => Cow::Borrowed("[reasoning]"),
1195 })),
1196 ),
1197 };
1198
1199 if text.is_empty() {
1200 String::new()
1201 } else {
1202 format!("{role}: {text}")
1203 }
1204}
1205
1206fn join_parts<'a>(parts: impl Iterator<Item = Cow<'a, str>>) -> String {
1208 let mut text = String::new();
1209 for part in parts {
1210 if !text.is_empty() {
1211 text.push(' ');
1212 }
1213 text.push_str(&part);
1214 }
1215 text
1216}
1217
1218#[cfg(test)]
1219mod tests;