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(
333 reasoning
334 .open(reasoning.issuer())
335 .map_or(0, |reasoning| reasoning.display_text().len()),
336 ),
337 AssistantContent::ToolCall(call) => {
338 let name_bytes = call.function.name.len();
339 let args_bytes = call.function.arguments.to_string().len();
340 self.bytes_to_tokens(name_bytes + args_bytes)
341 }
342 AssistantContent::Image(_) => self.per_attachment_tokens,
343 }
344 }
345}
346
347impl Default for HeuristicTokenCounter {
348 fn default() -> Self {
350 Self::new(4.0, 4, 256)
351 }
352}
353
354impl TokenCounter for HeuristicTokenCounter {
355 fn count(&self, message: &Message) -> usize {
356 let content_tokens: usize = match message {
357 Message::User { content } => content.iter().map(|c| self.count_user(c)).sum(),
358 Message::Assistant { content, .. } => {
359 content.iter().map(|c| self.count_assistant(c)).sum()
360 }
361 Message::System { content } => self.bytes_to_tokens(content.len()),
362 };
363 content_tokens.saturating_add(self.per_message_overhead)
364 }
365}
366
367pub struct TokenWindowMemory {
376 max_tokens: usize,
377 counter: Arc<dyn TokenCounter>,
378}
379
380impl TokenWindowMemory {
381 pub fn new<C>(max_tokens: usize, counter: C) -> Self
383 where
384 C: TokenCounter + 'static,
385 {
386 Self {
387 max_tokens,
388 counter: Arc::new(counter),
389 }
390 }
391}
392
393impl std::fmt::Debug for TokenWindowMemory {
394 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
395 f.debug_struct("TokenWindowMemory")
396 .field("max_tokens", &self.max_tokens)
397 .field("counter", &"<counter>")
398 .finish()
399 }
400}
401
402impl MemoryPolicy for TokenWindowMemory {
403 fn apply(&self, messages: Vec<Message>) -> Result<Vec<Message>, MemoryError> {
404 Ok(self.apply_with_demoted(messages)?.0)
405 }
406
407 fn apply_with_demoted(
408 &self,
409 messages: Vec<Message>,
410 ) -> Result<(Vec<Message>, Vec<Message>), MemoryError> {
411 let mut budget = self.max_tokens;
412 let mut keep_from = messages.len();
413
414 for (idx, msg) in messages.iter().enumerate().rev() {
415 let cost = self.counter.count(msg);
416 if cost > budget {
417 break;
418 }
419 budget -= cost;
420 keep_from = idx;
421 }
422
423 Ok(split_window(messages, keep_from))
424 }
425}
426
427#[derive(Debug, Clone, Copy)]
441pub struct PolicyMemory<M, P> {
442 inner: M,
443 policy: P,
444}
445
446impl<M, P> PolicyMemory<M, P> {
447 pub fn new(inner: M, policy: P) -> Self {
449 Self { inner, policy }
450 }
451
452 pub fn inner(&self) -> &M {
454 &self.inner
455 }
456
457 pub fn policy(&self) -> &P {
459 &self.policy
460 }
461
462 pub fn into_inner(self) -> (M, P) {
464 (self.inner, self.policy)
465 }
466}
467
468impl<M, P> ConversationMemory for PolicyMemory<M, P>
469where
470 M: ConversationMemory,
471 P: MemoryPolicy,
472{
473 fn load<'a>(
474 &'a self,
475 conversation_id: &'a ConversationId,
476 ) -> WasmBoxedFuture<'a, Result<Vec<Message>, MemoryError>> {
477 Box::pin(async move {
478 let messages = self.inner.load(conversation_id).await?;
479 self.policy.apply(messages)
480 })
481 }
482
483 fn append<'a>(
484 &'a self,
485 conversation_id: &'a ConversationId,
486 messages: Vec<Message>,
487 ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
488 self.inner.append(conversation_id, messages)
489 }
490
491 fn clear<'a>(
492 &'a self,
493 conversation_id: &'a ConversationId,
494 ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
495 self.inner.clear(conversation_id)
496 }
497}
498
499pub struct DemotingPolicyMemory<M, P, H> {
525 inner: M,
526 policy: P,
527 hook: H,
528 state: StdMutex<HashMap<ConversationId, ConversationDemotionState>>,
529}
530
531type InFlightReservation = Arc<()>;
532
533macro_rules! stateful_wrapper_common {
538 ($ty:ident, $third:ident: $tgen:ident $(: $tbound:ident)?) => {
539 impl<M, P, $tgen $(: $tbound)?> $ty<M, P, $tgen> {
540 pub fn inner(&self) -> &M {
542 &self.inner
543 }
544
545 pub fn policy(&self) -> &P {
547 &self.policy
548 }
549
550 pub fn $third(&self) -> &$tgen {
552 &self.$third
553 }
554
555 pub fn into_inner(self) -> (M, P, $tgen) {
557 (self.inner, self.policy, self.$third)
558 }
559
560 pub fn forget(&self, conversation_id: &ConversationId) {
565 if let Ok(mut guard) = self.state.lock() {
566 guard.remove(conversation_id);
567 }
568 }
569
570 pub fn tracked_conversations(&self) -> usize {
574 self.state.lock().map_or(0, |g| g.len())
575 }
576 }
577
578 impl<M, P, $tgen $(: $tbound)?> std::fmt::Debug for $ty<M, P, $tgen>
579 where
580 M: std::fmt::Debug,
581 P: std::fmt::Debug,
582 {
583 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
584 f.debug_struct(stringify!($ty))
585 .field("inner", &self.inner)
586 .field("policy", &self.policy)
587 .field(
588 stringify!($third),
589 &concat!("<", stringify!($third), ">"),
590 )
591 .finish()
592 }
593 }
594 };
595}
596
597macro_rules! stateful_wrapper_append_clear {
600 () => {
601 fn append<'a>(
602 &'a self,
603 conversation_id: &'a ConversationId,
604 messages: Vec<Message>,
605 ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
606 self.inner.append(conversation_id, messages)
607 }
608
609 fn clear<'a>(
610 &'a self,
611 conversation_id: &'a ConversationId,
612 ) -> WasmBoxedFuture<'a, Result<(), MemoryError>> {
613 Box::pin(async move {
614 self.inner.clear(conversation_id).await?;
615 self.forget(conversation_id);
616 Ok(())
617 })
618 }
619 };
620}
621
622#[derive(Debug, Default, Clone)]
623struct ConversationDemotionState {
624 delivered: usize,
627 in_flight: Option<InFlightReservation>,
631}
632
633impl<M, P, H> DemotingPolicyMemory<M, P, H> {
634 pub fn new(inner: M, policy: P, hook: H) -> Self {
637 Self {
638 inner,
639 policy,
640 hook,
641 state: StdMutex::new(HashMap::new()),
642 }
643 }
644}
645
646stateful_wrapper_common!(DemotingPolicyMemory, hook: H);
647
648impl<M, P, H> ConversationMemory for DemotingPolicyMemory<M, P, H>
649where
650 M: ConversationMemory,
651 P: MemoryPolicy,
652 H: DemotionHook,
653{
654 fn load<'a>(
655 &'a self,
656 conversation_id: &'a ConversationId,
657 ) -> WasmBoxedFuture<'a, Result<Vec<Message>, MemoryError>> {
658 Box::pin(async move {
659 let messages = self.inner.load(conversation_id).await?;
660 let (kept, mut demoted) = self.policy.apply_with_demoted(messages)?;
661 let demoted_count = demoted.len();
662
663 let (pending, reservation) = {
666 let mut guard = self.state.lock().map_err(poisoned)?;
667 if let Some(entry) = guard.get_mut(conversation_id) {
668 if entry.in_flight.is_some() {
669 return Ok(kept);
673 }
674 if entry.delivered >= demoted_count {
675 (Vec::new(), None)
676 } else {
677 let split = entry.delivered;
678 let reservation = Arc::new(());
679 entry.in_flight = Some(reservation.clone());
680 (demoted.split_off(split), Some(reservation))
681 }
682 } else if demoted_count == 0 {
683 (Vec::new(), None)
684 } else {
685 let reservation = Arc::new(());
686 guard.insert(
687 conversation_id.clone(),
688 ConversationDemotionState {
689 delivered: 0,
690 in_flight: Some(reservation.clone()),
691 },
692 );
693 (std::mem::take(&mut demoted), Some(reservation))
694 }
695 };
696
697 let Some(reservation) = reservation else {
698 return Ok(kept);
699 };
700
701 let in_flight_guard =
707 InFlightGuard::new(&self.state, conversation_id, reservation.clone());
708
709 let result = self.hook.on_demote(conversation_id, pending).await;
710
711 release_in_flight(&self.state, conversation_id, &reservation, |entry| {
714 if result.is_ok() {
715 entry.delivered = demoted_count;
716 }
717 })?;
718 in_flight_guard.disarm();
719 result?;
720 Ok(kept)
721 })
722 }
723
724 stateful_wrapper_append_clear!();
725}
726
727fn poisoned<E: std::fmt::Display>(err: E) -> MemoryError {
728 MemoryError::Internal(err.to_string())
729}
730
731fn release_in_flight<S: InFlightSlot, T>(
735 state: &StdMutex<HashMap<ConversationId, S>>,
736 key: &ConversationId,
737 reservation: &InFlightReservation,
738 on_match: impl FnOnce(&mut S) -> T,
739) -> Result<Option<T>, MemoryError> {
740 let mut guard = state.lock().map_err(poisoned)?;
741 let Some(entry) = guard.get_mut(key) else {
742 return Ok(None);
743 };
744 if entry
745 .in_flight_mut()
746 .as_ref()
747 .is_some_and(|current| Arc::ptr_eq(current, reservation))
748 {
749 *entry.in_flight_mut() = None;
750 return Ok(Some(on_match(entry)));
751 }
752 Ok(None)
753}
754
755trait InFlightSlot {
759 fn in_flight_mut(&mut self) -> &mut Option<InFlightReservation>;
760}
761
762impl InFlightSlot for ConversationDemotionState {
763 fn in_flight_mut(&mut self) -> &mut Option<InFlightReservation> {
764 &mut self.in_flight
765 }
766}
767
768impl<A> InFlightSlot for ConversationCompactionState<A> {
769 fn in_flight_mut(&mut self) -> &mut Option<InFlightReservation> {
770 &mut self.in_flight
771 }
772}
773
774struct InFlightGuard<'a, S: InFlightSlot> {
778 state: &'a StdMutex<HashMap<ConversationId, S>>,
779 key: &'a ConversationId,
780 reservation: InFlightReservation,
781 armed: bool,
782}
783
784impl<'a, S: InFlightSlot> InFlightGuard<'a, S> {
785 fn new(
786 state: &'a StdMutex<HashMap<ConversationId, S>>,
787 key: &'a ConversationId,
788 reservation: InFlightReservation,
789 ) -> Self {
790 Self {
791 state,
792 key,
793 reservation,
794 armed: true,
795 }
796 }
797
798 fn disarm(mut self) {
801 self.armed = false;
802 }
803}
804
805impl<S: InFlightSlot> Drop for InFlightGuard<'_, S> {
806 fn drop(&mut self) {
807 if !self.armed {
808 return;
809 }
810 let _ = release_in_flight(self.state, self.key, &self.reservation, |_| ());
812 }
813}
814
815pub struct CompactingMemory<M, P, C: Compactor> {
841 inner: M,
842 policy: P,
843 compactor: C,
844 state: StdMutex<HashMap<ConversationId, ConversationCompactionState<C::Artifact>>>,
845}
846
847struct ConversationCompactionState<A> {
848 summary: Option<A>,
851 absorbed: usize,
854 in_flight: Option<InFlightReservation>,
858}
859
860impl<M, P, C: Compactor> CompactingMemory<M, P, C> {
861 pub fn new(inner: M, policy: P, compactor: C) -> Self {
864 Self {
865 inner,
866 policy,
867 compactor,
868 state: StdMutex::new(HashMap::new()),
869 }
870 }
871}
872
873stateful_wrapper_common!(CompactingMemory, compactor: C: Compactor);
874
875impl<M, P, C> ConversationMemory for CompactingMemory<M, P, C>
876where
877 M: ConversationMemory,
878 P: MemoryPolicy,
879 C: Compactor,
880{
881 fn load<'a>(
882 &'a self,
883 conversation_id: &'a ConversationId,
884 ) -> WasmBoxedFuture<'a, Result<Vec<Message>, MemoryError>> {
885 Box::pin(async move {
886 let messages = self.inner.load(conversation_id).await?;
887 let (kept, demoted) = self.policy.apply_with_demoted(messages)?;
888 let demoted_count = demoted.len();
889
890 let plan = {
893 let mut guard = self.state.lock().map_err(poisoned)?;
894 if let Some(entry) = guard.get_mut(conversation_id) {
895 if entry.in_flight.is_some() {
896 return Ok(splice(entry.summary.clone(), kept));
900 }
901 if demoted_count <= entry.absorbed {
902 return Ok(splice(entry.summary.clone(), kept));
903 }
904 let reservation = Arc::new(());
905 entry.in_flight = Some(reservation.clone());
906 CompactionPlan {
907 carry_over: entry.summary.clone(),
908 skip: entry.absorbed,
909 reservation,
910 }
911 } else if demoted_count == 0 {
912 return Ok(kept);
913 } else {
914 let reservation = Arc::new(());
915 guard.insert(
916 conversation_id.clone(),
917 ConversationCompactionState {
918 summary: None,
919 absorbed: 0,
920 in_flight: Some(reservation.clone()),
921 },
922 );
923 CompactionPlan {
924 carry_over: None,
925 skip: 0,
926 reservation,
927 }
928 }
929 };
930
931 let CompactionPlan {
933 carry_over,
934 skip,
935 reservation,
936 } = plan;
937
938 let in_flight_guard =
944 InFlightGuard::new(&self.state, conversation_id, reservation.clone());
945
946 let Some(new_slice) = demoted.get(skip..) else {
947 drop(in_flight_guard);
950 return Err(MemoryError::Internal(
951 "compaction watermark exceeds demoted slice length".into(),
952 ));
953 };
954
955 let result = self
956 .compactor
957 .compact(conversation_id, new_slice, carry_over.as_ref())
958 .await;
959
960 let summary_for_splice = match result {
963 Ok(artifact) => {
964 release_in_flight(&self.state, conversation_id, &reservation, |entry| {
965 entry.absorbed = demoted_count;
966 entry.summary = Some(artifact.clone());
967 artifact
968 })?
969 }
970 Err(err) => {
971 release_in_flight(&self.state, conversation_id, &reservation, |_| ())?;
972 return Err(err);
973 }
974 };
975
976 in_flight_guard.disarm();
978
979 Ok(splice(summary_for_splice, kept))
980 })
981 }
982
983 stateful_wrapper_append_clear!();
984}
985
986struct CompactionPlan<A> {
987 carry_over: Option<A>,
988 skip: usize,
989 reservation: InFlightReservation,
990}
991
992fn splice<A>(summary: Option<A>, kept: Vec<Message>) -> Vec<Message>
993where
994 A: Into<Message>,
995{
996 match summary {
997 Some(artifact) => {
998 let mut out = Vec::with_capacity(kept.len() + 1);
999 out.push(artifact.into());
1000 out.extend(kept);
1001 out
1002 }
1003 None => kept,
1004 }
1005}
1006
1007#[derive(Debug, Clone)]
1026pub struct TemplateCompactor {
1027 header: String,
1028 max_bytes: Option<usize>,
1029}
1030
1031impl TemplateCompactor {
1032 pub fn new() -> Self {
1035 Self::with_header("[Conversation summary so far]")
1036 }
1037
1038 pub fn with_header(header: impl Into<String>) -> Self {
1041 Self {
1042 header: header.into(),
1043 max_bytes: None,
1044 }
1045 }
1046
1047 #[must_use = "the setting applies to the returned value"]
1056 pub fn with_max_bytes(mut self, max_bytes: usize) -> Self {
1057 self.max_bytes = if max_bytes == 0 {
1058 None
1059 } else {
1060 Some(max_bytes)
1061 };
1062 self
1063 }
1064}
1065
1066impl Default for TemplateCompactor {
1067 fn default() -> Self {
1068 Self::new()
1069 }
1070}
1071
1072#[derive(Debug, Clone)]
1079pub struct TextSummary(String);
1080
1081impl TextSummary {
1082 pub fn as_str(&self) -> &str {
1084 &self.0
1085 }
1086
1087 pub fn into_string(self) -> String {
1089 self.0
1090 }
1091}
1092
1093impl From<TextSummary> for Message {
1094 fn from(value: TextSummary) -> Self {
1095 Message::System { content: value.0 }
1096 }
1097}
1098
1099impl Compactor for TemplateCompactor {
1100 type Artifact = TextSummary;
1101
1102 fn compact<'a>(
1103 &'a self,
1104 _conversation_id: &'a ConversationId,
1105 evicted: &'a [Message],
1106 carry_over: Option<&'a Self::Artifact>,
1107 ) -> WasmBoxedFuture<'a, Result<Self::Artifact, MemoryError>> {
1108 Box::pin(async move {
1109 let mut buf = String::new();
1110 buf.push_str(&self.header);
1111 buf.push('\n');
1112 if let Some(prev) = carry_over {
1113 buf.push_str(prev.as_str());
1114 buf.push('\n');
1115 }
1116 for msg in evicted {
1117 let line = render_message_line(msg);
1118 if !line.is_empty() {
1119 buf.push_str(&line);
1120 buf.push('\n');
1121 }
1122 }
1123 if let Some(cap) = self.max_bytes
1124 && buf.len() > cap
1125 {
1126 buf = truncate_summary(&buf, cap);
1127 }
1128 Ok(TextSummary(buf))
1129 })
1130 }
1131}
1132
1133fn truncate_summary(buf: &str, cap: usize) -> String {
1141 const MARKER: &str = "[\u{2026}truncated\u{2026}]\n";
1142 let Some(newline) = buf.find('\n') else {
1143 return buf.to_string();
1144 };
1145 let header_prefix_len = newline + 1;
1146 if buf.len() <= header_prefix_len {
1147 return buf.to_string();
1148 }
1149 let preserved = header_prefix_len + MARKER.len();
1150 let keep_bytes = cap.saturating_sub(preserved);
1151 let body_start = header_prefix_len;
1152 let Some(body) = buf.get(body_start..) else {
1153 return buf.to_string();
1154 };
1155 let mut cut = body.len().saturating_sub(keep_bytes);
1156 while cut < body.len() && !body.is_char_boundary(cut) {
1157 cut += 1;
1158 }
1159 let suffix: &str = body.get(cut..).unwrap_or_default();
1160 let Some(header_with_nl) = buf.get(..header_prefix_len) else {
1161 return buf.to_string();
1162 };
1163 let mut out = String::with_capacity(header_prefix_len + MARKER.len() + suffix.len());
1164 out.push_str(header_with_nl);
1165 out.push_str(MARKER);
1166 out.push_str(suffix);
1167 out
1168}
1169
1170fn render_message_line(msg: &Message) -> String {
1176 use rig_core::message::AssistantContent;
1177
1178 let (role, text) = match msg {
1179 Message::System { content } => ("system", content.clone()),
1180 Message::User { content } => (
1181 "user",
1182 join_parts(content.iter().map(|item| match item {
1183 UserContent::Text(text) => Cow::Borrowed(text.text.as_str()),
1184 UserContent::ToolResult(_) => Cow::Borrowed("[tool result]"),
1185 _ => Cow::Borrowed("[attachment]"),
1186 })),
1187 ),
1188 Message::Assistant { content, .. } => (
1189 "assistant",
1190 join_parts(content.iter().map(|item| match item {
1191 AssistantContent::Text(text) => Cow::Borrowed(text.text.as_str()),
1192 AssistantContent::ToolCall(call) => {
1193 Cow::Owned(format!("[tool call: {}]", call.function.name))
1194 }
1195 _ => Cow::Borrowed("[reasoning]"),
1196 })),
1197 ),
1198 };
1199
1200 if text.is_empty() {
1201 String::new()
1202 } else {
1203 format!("{role}: {text}")
1204 }
1205}
1206
1207fn join_parts<'a>(parts: impl Iterator<Item = Cow<'a, str>>) -> String {
1209 let mut text = String::new();
1210 for part in parts {
1211 if !text.is_empty() {
1212 text.push(' ');
1213 }
1214 text.push_str(&part);
1215 }
1216 text
1217}
1218
1219#[cfg(test)]
1220mod tests;