1use async_trait::async_trait;
106use redis::AsyncCommands;
107use serde::{Deserialize, Serialize};
108use tokio::sync::{Mutex, OnceCell};
109use uuid::Uuid;
110
111use agent_framework_core::error::{Error, Result};
112use agent_framework_core::memory::{ContextProvider, SessionContext};
113use agent_framework_core::types::{Message, Role};
114
115use crate::internal::{map_redis_err, LazyConnection};
116
117pub const DEFAULT_KEY_PREFIX: &str = "context";
119
120pub const DEFAULT_CONTEXT_PROMPT: &str =
123 "## Memories\nConsider the following memories when answering user questions:";
124
125pub const DEFAULT_LIMIT: usize = 10;
128
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138struct MemoryEntry {
139 content: String,
140 role: String,
141 #[serde(skip_serializing_if = "Option::is_none", default)]
142 application_id: Option<String>,
143 #[serde(skip_serializing_if = "Option::is_none", default)]
144 agent_id: Option<String>,
145 #[serde(skip_serializing_if = "Option::is_none", default)]
146 user_id: Option<String>,
147 #[serde(skip_serializing_if = "Option::is_none", default)]
148 thread_id: Option<String>,
149 #[serde(skip_serializing_if = "Option::is_none", default)]
150 message_id: Option<String>,
151 #[serde(skip_serializing_if = "Option::is_none", default)]
152 author_name: Option<String>,
153 rank: i64,
162}
163
164#[derive(Debug, Clone, Default, PartialEq)]
169struct Scope {
170 application_id: Option<String>,
171 agent_id: Option<String>,
172 user_id: Option<String>,
173 thread_id: Option<String>,
174}
175
176impl Scope {
177 fn is_empty(&self) -> bool {
178 self.application_id.is_none()
179 && self.agent_id.is_none()
180 && self.user_id.is_none()
181 && self.thread_id.is_none()
182 }
183
184 fn matches(&self, entry: &MemoryEntry) -> bool {
185 fn field_matches(configured: &Option<String>, actual: &Option<String>) -> bool {
186 match configured {
187 None => true,
188 Some(want) => actual.as_deref() == Some(want.as_str()),
189 }
190 }
191 field_matches(&self.application_id, &entry.application_id)
192 && field_matches(&self.agent_id, &entry.agent_id)
193 && field_matches(&self.user_id, &entry.user_id)
194 && field_matches(&self.thread_id, &entry.thread_id)
195 }
196}
197
198const STOPWORDS: &[&str] = &[
208 "a", "an", "the", "is", "are", "was", "were", "be", "been", "being", "of", "in", "on", "at",
209 "to", "for", "and", "or", "but", "not", "with", "by", "from", "as", "it", "its", "this",
210 "that", "these", "those", "i", "you", "he", "she", "we", "they", "what", "which", "who",
211 "whom", "do", "does", "did", "have", "has", "had", "my", "your", "me", "about",
212];
213
214fn tokenize_query(query: &str) -> Vec<String> {
222 query
223 .to_lowercase()
224 .split(|c: char| !c.is_alphanumeric())
225 .filter(|t| !t.is_empty() && !STOPWORDS.contains(t))
226 .map(str::to_string)
227 .collect()
228}
229
230fn select_recent(
237 mut entries: Vec<MemoryEntry>,
238 scope: &Scope,
239 query: Option<&str>,
240 limit: usize,
241) -> Vec<MemoryEntry> {
242 entries.retain(|e| scope.matches(e));
243
244 if let Some(q) = query {
245 let tokens = tokenize_query(q);
246 if !tokens.is_empty() {
247 entries.retain(|e| {
248 let content_lower = e.content.to_lowercase();
249 tokens.iter().any(|t| content_lower.contains(t.as_str()))
250 });
251 }
252 }
253
254 entries.sort_by_key(|e| std::cmp::Reverse(e.rank));
255 entries.truncate(limit);
256 entries
257}
258
259fn is_storable_role(role: &Role) -> bool {
260 let r = role.as_str();
261 r == Role::USER || r == Role::ASSISTANT || r == Role::SYSTEM
262}
263
264fn format_context_message(context_prompt: &str, joined_memories: &str) -> Option<Message> {
268 if joined_memories.is_empty() {
269 None
270 } else {
271 Some(Message::user(format!(
272 "{context_prompt}\n{joined_memories}"
273 )))
274 }
275}
276
277fn now_millis() -> i64 {
278 use std::time::{SystemTime, UNIX_EPOCH};
279 SystemTime::now()
280 .duration_since(UNIX_EPOCH)
281 .unwrap_or_default()
282 .as_millis() as i64
283}
284
285const REDISEARCH_SPECIAL_CHARS: &[char] = &[
296 ',', '.', '<', '>', '{', '}', '[', ']', '"', '\'', ':', ';', '!', '@', '#', '$', '%', '^', '&',
297 '*', '(', ')', '-', '+', '=', '~', '|', ' ', '\\',
298];
299
300fn escape_redisearch(value: &str) -> String {
303 let mut out = String::with_capacity(value.len());
304 for c in value.chars() {
305 if REDISEARCH_SPECIAL_CHARS.contains(&c) {
306 out.push('\\');
307 }
308 out.push(c);
309 }
310 out
311}
312
313fn ft_create_args(key_prefix: &str, index_name: &str) -> Vec<String> {
323 const TAG_FIELDS: &[&str] = &[
324 "role",
325 "application_id",
326 "agent_id",
327 "user_id",
328 "thread_id",
329 "message_id",
330 "author_name",
331 ];
332
333 let mut args: Vec<String> = vec![
334 index_name.to_string(),
335 "ON".to_string(),
336 "JSON".to_string(),
337 "PREFIX".to_string(),
338 "1".to_string(),
339 format!("{key_prefix}:entry:"),
340 "SCHEMA".to_string(),
341 "$.content".to_string(),
342 "AS".to_string(),
343 "content".to_string(),
344 "TEXT".to_string(),
345 ];
346
347 for field in TAG_FIELDS {
348 args.push(format!("$.{field}"));
349 args.push("AS".to_string());
350 args.push((*field).to_string());
351 args.push("TAG".to_string());
352 }
353
354 args.push("$.rank".to_string());
355 args.push("AS".to_string());
356 args.push("rank".to_string());
357 args.push("NUMERIC".to_string());
358 args.push("SORTABLE".to_string());
359
360 args
361}
362
363fn build_ft_search_query(scope: &Scope, tokens: &[String]) -> String {
374 let mut clauses = Vec::new();
375 if let Some(v) = &scope.application_id {
376 clauses.push(format!("@application_id:{{{}}}", escape_redisearch(v)));
377 }
378 if let Some(v) = &scope.agent_id {
379 clauses.push(format!("@agent_id:{{{}}}", escape_redisearch(v)));
380 }
381 if let Some(v) = &scope.user_id {
382 clauses.push(format!("@user_id:{{{}}}", escape_redisearch(v)));
383 }
384 if let Some(v) = &scope.thread_id {
385 clauses.push(format!("@thread_id:{{{}}}", escape_redisearch(v)));
386 }
387 if !tokens.is_empty() {
388 let ored = tokens
389 .iter()
390 .map(|t| escape_redisearch(t))
391 .collect::<Vec<_>>()
392 .join("|");
393 clauses.push(format!("@content:({ored})"));
394 }
395 if clauses.is_empty() {
396 "*".to_string()
397 } else {
398 clauses.join(" ")
399 }
400}
401
402fn parse_ft_search_reply(value: &redis::Value) -> Vec<MemoryEntry> {
414 let redis::Value::Array(items) = value else {
415 return Vec::new();
416 };
417 let mut out = Vec::new();
418 let mut i = 1; while i + 1 < items.len() {
420 if let redis::Value::Array(fields) = &items[i + 1] {
421 let mut j = 0;
422 while j + 1 < fields.len() {
423 if let redis::Value::BulkString(name) = &fields[j] {
424 if name == b"$" {
425 if let redis::Value::BulkString(raw) = &fields[j + 1] {
426 if let Ok(text) = std::str::from_utf8(raw) {
427 if let Ok(entry) = serde_json::from_str::<MemoryEntry>(text) {
428 out.push(entry);
429 }
430 }
431 }
432 }
433 }
434 j += 2;
435 }
436 }
437 i += 2;
438 }
439 out
440}
441
442fn is_index_exists_error(message: &str) -> bool {
449 message.to_lowercase().contains("index already exists")
450}
451
452async fn probe_redisearch(conn: &mut redis::aio::MultiplexedConnection) -> bool {
459 redis::cmd("FT._LIST")
460 .query_async::<Vec<String>>(conn)
461 .await
462 .is_ok()
463}
464
465pub struct RedisContextProvider {
493 conn: LazyConnection,
494 key_prefix: String,
495 application_id: Option<String>,
496 agent_id: Option<String>,
497 user_id: Option<String>,
498 thread_id: Option<String>,
499 scope_to_per_operation_thread_id: bool,
500 context_prompt: String,
501 limit: usize,
502 session_thread_id: Mutex<Option<String>>,
510 force_scan_fallback: bool,
513 search_capability: OnceCell<bool>,
516 index_ready: OnceCell<()>,
519}
520
521impl RedisContextProvider {
522 pub fn new(redis_url: impl Into<String>) -> Result<Self> {
526 let redis_url = redis_url.into();
527 let conn = LazyConnection::open(&redis_url)?;
528 Ok(Self {
529 conn,
530 key_prefix: DEFAULT_KEY_PREFIX.to_string(),
531 application_id: None,
532 agent_id: None,
533 user_id: None,
534 thread_id: None,
535 scope_to_per_operation_thread_id: false,
536 context_prompt: DEFAULT_CONTEXT_PROMPT.to_string(),
537 limit: DEFAULT_LIMIT,
538 session_thread_id: Mutex::new(None),
539 force_scan_fallback: false,
540 search_capability: OnceCell::new(),
541 index_ready: OnceCell::new(),
542 })
543 }
544
545 pub fn with_key_prefix(mut self, key_prefix: impl Into<String>) -> Self {
548 self.key_prefix = key_prefix.into();
549 self
550 }
551
552 pub fn with_application_id(mut self, application_id: impl Into<String>) -> Self {
554 self.application_id = Some(application_id.into());
555 self
556 }
557
558 pub fn with_agent_id(mut self, agent_id: impl Into<String>) -> Self {
560 self.agent_id = Some(agent_id.into());
561 self
562 }
563
564 pub fn with_user_id(mut self, user_id: impl Into<String>) -> Self {
566 self.user_id = Some(user_id.into());
567 self
568 }
569
570 pub fn with_thread_id(mut self, thread_id: impl Into<String>) -> Self {
572 self.thread_id = Some(thread_id.into());
573 self
574 }
575
576 pub fn with_scope_to_per_operation_thread_id(mut self, value: bool) -> Self {
582 self.scope_to_per_operation_thread_id = value;
583 self
584 }
585
586 pub fn with_context_prompt(mut self, context_prompt: impl Into<String>) -> Self {
589 self.context_prompt = context_prompt.into();
590 self
591 }
592
593 pub fn with_limit(mut self, limit: usize) -> Self {
596 self.limit = limit;
597 self
598 }
599
600 pub fn with_force_scan_fallback(mut self, force: bool) -> Self {
608 self.force_scan_fallback = force;
609 self
610 }
611
612 fn validate_filters(&self) -> Result<()> {
613 if self.application_id.is_none()
614 && self.agent_id.is_none()
615 && self.user_id.is_none()
616 && self.thread_id.is_none()
617 {
618 return Err(Error::Configuration(
619 "At least one of the filters: agent_id, user_id, application_id, or thread_id is required."
620 .into(),
621 ));
622 }
623 Ok(())
624 }
625
626 async fn effective_thread_id(&self) -> Option<String> {
627 if self.scope_to_per_operation_thread_id {
628 self.session_thread_id.lock().await.clone()
629 } else {
630 self.thread_id.clone()
631 }
632 }
633
634 async fn record_session_thread_id(&self, session_id: Option<&str>) -> Result<()> {
645 let mut guard = self.session_thread_id.lock().await;
646 if self.scope_to_per_operation_thread_id {
647 if let (Some(new_id), Some(existing)) = (session_id, guard.as_deref()) {
648 if new_id != existing {
649 return Err(Error::other(
650 "RedisContextProvider can only be used with one thread, when scope_to_per_operation_thread_id is True.",
651 ));
652 }
653 }
654 }
655 if guard.is_none() {
656 *guard = session_id.map(String::from);
657 }
658 Ok(())
659 }
660
661 async fn scope(&self) -> Scope {
662 Scope {
663 application_id: self.application_id.clone(),
664 agent_id: self.agent_id.clone(),
665 user_id: self.user_id.clone(),
666 thread_id: self.effective_thread_id().await,
667 }
668 }
669
670 fn entry_key(&self, id: &str) -> String {
671 format!("{}:entry:{}", self.key_prefix, id)
672 }
673
674 fn scan_pattern(&self) -> String {
675 format!("{}:entry:*", self.key_prefix)
676 }
677
678 fn index_name(&self) -> String {
682 format!("{}_idx", self.key_prefix)
683 }
684
685 async fn scan_entries(&self) -> Result<Vec<MemoryEntry>> {
692 let mut conn = self.conn.get().await?;
693 let pattern = self.scan_pattern();
694
695 let mut keys: Vec<String> = Vec::new();
696 {
697 let mut iter: redis::AsyncIter<'_, String> =
698 conn.scan_match(&pattern).await.map_err(map_redis_err)?;
699 while let Some(item) = iter.next_item().await {
700 keys.push(item.map_err(map_redis_err)?);
701 }
702 }
703 if keys.is_empty() {
704 return Ok(Vec::new());
705 }
706
707 let raw: Vec<Option<String>> = conn.mget(&keys).await.map_err(map_redis_err)?;
708 Ok(raw
709 .into_iter()
710 .flatten()
711 .filter_map(|v| serde_json::from_str::<MemoryEntry>(&v).ok())
712 .collect())
713 }
714
715 async fn use_redisearch(&self, conn: &mut redis::aio::MultiplexedConnection) -> bool {
722 if self.force_scan_fallback {
723 return false;
724 }
725 *self
726 .search_capability
727 .get_or_init(move || async move { probe_redisearch(conn).await })
728 .await
729 }
730
731 async fn ensure_index(&self, conn: &mut redis::aio::MultiplexedConnection) -> Result<()> {
737 let args = ft_create_args(&self.key_prefix, &self.index_name());
738 self.index_ready
739 .get_or_try_init(move || async move {
740 let mut cmd = redis::cmd("FT.CREATE");
741 for arg in args {
742 cmd.arg(arg);
743 }
744 match cmd.query_async::<redis::Value>(conn).await {
745 Ok(_) => Ok(()),
746 Err(e) if is_index_exists_error(&e.to_string()) => Ok(()),
747 Err(e) => Err(map_redis_err(e)),
748 }
749 })
750 .await?;
751 Ok(())
752 }
753
754 async fn ft_search(
759 &self,
760 conn: &mut redis::aio::MultiplexedConnection,
761 scope: &Scope,
762 tokens: &[String],
763 limit: usize,
764 ) -> Result<Vec<MemoryEntry>> {
765 self.ensure_index(conn).await?;
766 let query = build_ft_search_query(scope, tokens);
767 let mut cmd = redis::cmd("FT.SEARCH");
768 cmd.arg(self.index_name())
769 .arg(&query)
770 .arg("RETURN")
771 .arg(1)
772 .arg("$")
773 .arg("LIMIT")
774 .arg(0)
775 .arg(limit);
776 let reply = cmd
777 .query_async::<redis::Value>(conn)
778 .await
779 .map_err(map_redis_err)?;
780 Ok(parse_ft_search_reply(&reply))
781 }
782}
783
784#[async_trait]
785impl ContextProvider for RedisContextProvider {
786 async fn before_run(&self, ctx: &mut SessionContext) -> Result<()> {
787 self.validate_filters()?;
788 self.record_session_thread_id(ctx.session_id.as_deref())
794 .await?;
795
796 let input_text = ctx
797 .input_messages
798 .iter()
799 .map(Message::text)
800 .filter(|t| !t.trim().is_empty())
801 .collect::<Vec<_>>()
802 .join("\n");
803 if input_text.trim().is_empty() {
804 return Ok(());
808 }
809
810 let scope = self.scope().await;
811 if scope.is_empty() {
812 return Ok(());
815 }
816
817 let mut conn = self.conn.get().await?;
818 let hits = if self.use_redisearch(&mut conn).await {
819 let tokens = tokenize_query(&input_text);
820 self.ft_search(&mut conn, &scope, &tokens, self.limit)
821 .await?
822 } else {
823 let entries = self.scan_entries().await?;
824 select_recent(entries, &scope, Some(&input_text), self.limit)
825 };
826
827 let joined = hits
828 .iter()
829 .map(|e| e.content.as_str())
830 .filter(|c| !c.is_empty())
831 .collect::<Vec<_>>()
832 .join("\n");
833
834 if let Some(message) = format_context_message(&self.context_prompt, &joined) {
835 ctx.messages.push(message);
836 }
837
838 Ok(())
839 }
840
841 async fn after_run(
842 &self,
843 request_messages: &[Message],
844 response_messages: &[Message],
845 _error: Option<&Error>,
846 ) -> Result<()> {
847 self.validate_filters()?;
848 let scope = self.scope().await;
849 let now = now_millis();
850
851 let entries: Vec<MemoryEntry> = request_messages
852 .iter()
853 .chain(response_messages.iter())
854 .enumerate()
855 .filter(|(_, m)| is_storable_role(&m.role))
856 .filter_map(|(i, m)| {
857 let text = m.text();
858 if text.trim().is_empty() {
859 return None;
860 }
861 Some(MemoryEntry {
862 content: text,
863 role: m.role.as_str().to_string(),
864 application_id: scope.application_id.clone(),
865 agent_id: scope.agent_id.clone(),
866 user_id: scope.user_id.clone(),
867 thread_id: scope.thread_id.clone(),
868 message_id: m.message_id.clone(),
869 author_name: m.author_name.clone(),
870 rank: now * 1000 + i as i64,
871 })
872 })
873 .collect();
874
875 if entries.is_empty() {
876 return Ok(());
877 }
878
879 let mut conn = self.conn.get().await?;
880 let mut pipe = redis::pipe();
881 pipe.atomic();
882 if self.use_redisearch(&mut conn).await {
883 self.ensure_index(&mut conn).await?;
886 for entry in &entries {
887 let key = self.entry_key(&Uuid::new_v4().to_string());
888 let payload = serde_json::to_string(entry)?;
889 pipe.cmd("JSON.SET").arg(key).arg("$").arg(payload);
890 }
891 } else {
892 for entry in &entries {
893 let key = self.entry_key(&Uuid::new_v4().to_string());
894 let payload = serde_json::to_string(entry)?;
895 pipe.set(key, payload);
896 }
897 }
898 let _: () = pipe.query_async(&mut conn).await.map_err(map_redis_err)?;
899 Ok(())
900 }
901}
902
903#[cfg(test)]
904mod tests {
905 use super::*;
906
907 fn provider() -> RedisContextProvider {
908 RedisContextProvider::new("redis://127.0.0.1:6379/0")
909 .expect("valid redis url")
910 .with_user_id("u1")
911 }
912
913 fn entry(content: &str, rank: i64) -> MemoryEntry {
914 MemoryEntry {
915 content: content.to_string(),
916 role: "user".to_string(),
917 application_id: None,
918 agent_id: None,
919 user_id: Some("u1".to_string()),
920 thread_id: None,
921 message_id: None,
922 author_name: None,
923 rank,
924 }
925 }
926
927 #[test]
930 fn entry_key_and_scan_pattern_use_key_prefix() {
931 let p = provider();
932 assert_eq!(p.entry_key("abc"), "context:entry:abc");
933 assert_eq!(p.scan_pattern(), "context:entry:*");
934 }
935
936 #[test]
937 fn custom_key_prefix_propagates() {
938 let p = provider().with_key_prefix("myapp");
939 assert_eq!(p.entry_key("abc"), "myapp:entry:abc");
940 assert_eq!(p.scan_pattern(), "myapp:entry:*");
941 }
942
943 #[test]
944 fn invalid_redis_url_is_rejected() {
945 assert!(RedisContextProvider::new("not-a-redis-url").is_err());
946 }
947
948 #[test]
949 fn index_name_derives_from_key_prefix() {
950 let p = provider();
951 assert_eq!(p.index_name(), "context_idx");
952 let p = p.with_key_prefix("myapp");
953 assert_eq!(p.index_name(), "myapp_idx");
954 }
955
956 #[test]
961 fn validate_filters_rejects_no_scope() {
962 let p = RedisContextProvider::new("redis://127.0.0.1:6379/0").unwrap();
963 assert!(p.validate_filters().is_err());
964 }
965
966 #[test]
967 fn validate_filters_accepts_any_single_scope_field() {
968 let base = || RedisContextProvider::new("redis://127.0.0.1:6379/0").unwrap();
969 assert!(base().with_user_id("u").validate_filters().is_ok());
970 assert!(base().with_agent_id("a").validate_filters().is_ok());
971 assert!(base().with_application_id("ap").validate_filters().is_ok());
972 assert!(base().with_thread_id("t").validate_filters().is_ok());
973 }
974
975 #[test]
980 fn scope_with_no_fields_matches_everything() {
981 let scope = Scope::default();
982 assert!(scope.matches(&entry("hello", 1)));
983 }
984
985 #[test]
986 fn scope_field_is_wildcard_when_unset() {
987 let scope = Scope {
988 user_id: Some("u1".to_string()),
989 ..Default::default()
990 };
991 assert!(scope.matches(&entry("hi", 1)));
994 }
995
996 #[test]
997 fn scope_rejects_mismatched_field() {
998 let scope = Scope {
999 user_id: Some("someone-else".to_string()),
1000 ..Default::default()
1001 };
1002 assert!(!scope.matches(&entry("hi", 1)));
1003 }
1004
1005 #[test]
1006 fn scope_requires_all_configured_fields_to_match() {
1007 let mut e = entry("hi", 1);
1008 e.agent_id = Some("agentA".to_string());
1009 let scope = Scope {
1010 user_id: Some("u1".to_string()),
1011 agent_id: Some("agentB".to_string()),
1012 ..Default::default()
1013 };
1014 assert!(!scope.matches(&e));
1015 }
1016
1017 #[test]
1022 fn tokenize_query_lowercases_and_splits_on_non_alphanumeric() {
1023 assert_eq!(
1024 tokenize_query("Seattle, WA!"),
1025 vec!["seattle".to_string(), "wa".to_string()]
1026 );
1027 }
1028
1029 #[test]
1030 fn tokenize_query_drops_stopwords() {
1031 assert_eq!(
1032 tokenize_query("What is the capital of France?"),
1033 vec!["capital".to_string(), "france".to_string()]
1034 );
1035 }
1036
1037 #[test]
1038 fn tokenize_query_all_stopwords_yields_empty() {
1039 assert!(tokenize_query("is the of").is_empty());
1040 }
1041
1042 #[test]
1047 fn select_recent_orders_by_rank_descending() {
1048 let entries = vec![entry("old", 1), entry("newest", 3), entry("mid", 2)];
1049 let scope = Scope::default();
1050 let hits = select_recent(entries, &scope, None, 10);
1051 assert_eq!(
1052 hits.iter().map(|e| e.content.as_str()).collect::<Vec<_>>(),
1053 vec!["newest", "mid", "old"]
1054 );
1055 }
1056
1057 #[test]
1058 fn select_recent_respects_limit() {
1059 let entries = vec![entry("a", 1), entry("b", 2), entry("c", 3)];
1060 let hits = select_recent(entries, &Scope::default(), None, 2);
1061 assert_eq!(hits.len(), 2);
1062 assert_eq!(hits[0].content, "c");
1063 assert_eq!(hits[1].content, "b");
1064 }
1065
1066 #[test]
1067 fn select_recent_filters_by_scope() {
1068 let mut other_user = entry("secret", 5);
1069 other_user.user_id = Some("someone-else".to_string());
1070 let entries = vec![entry("mine", 1), other_user];
1071
1072 let scope = Scope {
1073 user_id: Some("u1".to_string()),
1074 ..Default::default()
1075 };
1076 let hits = select_recent(entries, &scope, None, 10);
1077 assert_eq!(hits.len(), 1);
1078 assert_eq!(hits[0].content, "mine");
1079 }
1080
1081 #[test]
1082 fn select_recent_text_filter_matches_token_case_insensitively() {
1083 let entries = vec![
1084 entry("User likes outdoor activities", 1),
1085 entry("User lives in Seattle", 2),
1086 entry("Completely unrelated fact", 3),
1087 ];
1088 let hits = select_recent(entries, &Scope::default(), Some("SEATTLE weather"), 10);
1089 assert_eq!(hits.len(), 1);
1090 assert_eq!(hits[0].content, "User lives in Seattle");
1091 }
1092
1093 #[test]
1094 fn select_recent_text_filter_excludes_non_matching() {
1095 let entries = vec![entry("apples and oranges", 1)];
1096 let hits = select_recent(entries, &Scope::default(), Some("bananas"), 10);
1097 assert!(hits.is_empty());
1098 }
1099
1100 #[test]
1101 fn select_recent_none_query_skips_text_filter() {
1102 let entries = vec![entry("anything at all", 1)];
1103 let hits = select_recent(entries, &Scope::default(), None, 10);
1104 assert_eq!(hits.len(), 1);
1105 }
1106
1107 #[test]
1108 fn select_recent_blank_query_skips_text_filter() {
1109 let entries = vec![entry("anything at all", 1)];
1110 let hits = select_recent(entries, &Scope::default(), Some(" "), 10);
1111 assert_eq!(hits.len(), 1);
1112 }
1113
1114 #[test]
1119 fn format_context_message_none_when_no_hits() {
1120 assert!(format_context_message(DEFAULT_CONTEXT_PROMPT, "").is_none());
1121 }
1122
1123 #[test]
1124 fn format_context_message_builds_user_message_with_prompt_header() {
1125 let message = format_context_message(DEFAULT_CONTEXT_PROMPT, "A\nB").unwrap();
1126 assert_eq!(message.role, Role::user());
1127 assert_eq!(
1128 message.text(),
1129 "## Memories\nConsider the following memories when answering user questions:\nA\nB"
1130 );
1131 }
1132
1133 #[test]
1138 fn is_storable_role_allows_user_assistant_system() {
1139 assert!(is_storable_role(&Role::user()));
1140 assert!(is_storable_role(&Role::assistant()));
1141 assert!(is_storable_role(&Role::system()));
1142 }
1143
1144 #[test]
1145 fn is_storable_role_rejects_tool() {
1146 assert!(!is_storable_role(&Role::tool()));
1147 }
1148
1149 #[test]
1154 fn escape_redisearch_leaves_plain_alphanumeric_untouched() {
1155 assert_eq!(escape_redisearch("hello123"), "hello123");
1156 }
1157
1158 #[test]
1159 fn escape_redisearch_escapes_hyphen_in_uuid_like_value() {
1160 assert_eq!(
1161 escape_redisearch("thread-42-abc"),
1162 "thread\\-42\\-abc".to_string()
1163 );
1164 }
1165
1166 #[test]
1167 fn escape_redisearch_escapes_email_like_value() {
1168 assert_eq!(
1169 escape_redisearch("user@example.com"),
1170 "user\\@example\\.com".to_string()
1171 );
1172 }
1173
1174 #[test]
1175 fn escape_redisearch_escapes_pipe_and_braces() {
1176 assert_eq!(escape_redisearch("a|b{c}"), "a\\|b\\{c\\}".to_string());
1177 }
1178
1179 #[test]
1180 fn escape_redisearch_escapes_backslash_itself() {
1181 assert_eq!(escape_redisearch("a\\b"), "a\\\\b".to_string());
1182 }
1183
1184 #[test]
1185 fn escape_redisearch_escapes_whitespace() {
1186 assert_eq!(escape_redisearch("two words"), "two\\ words".to_string());
1187 }
1188
1189 #[test]
1194 fn ft_create_args_starts_with_index_name_and_json_prefix() {
1195 let args = ft_create_args("context", "context_idx");
1196 assert_eq!(args[0], "context_idx");
1197 assert_eq!(args[1], "ON");
1198 assert_eq!(args[2], "JSON");
1199 assert_eq!(args[3], "PREFIX");
1200 assert_eq!(args[4], "1");
1201 assert_eq!(args[5], "context:entry:");
1202 assert_eq!(args[6], "SCHEMA");
1203 }
1204
1205 #[test]
1206 fn ft_create_args_content_field_is_text() {
1207 let args = ft_create_args("context", "context_idx");
1208 let pos = args.iter().position(|a| a == "$.content").unwrap();
1209 assert_eq!(args[pos + 1], "AS");
1210 assert_eq!(args[pos + 2], "content");
1211 assert_eq!(args[pos + 3], "TEXT");
1212 }
1213
1214 #[test]
1215 fn ft_create_args_scope_fields_are_tags() {
1216 let args = ft_create_args("context", "context_idx");
1217 for field in [
1218 "application_id",
1219 "agent_id",
1220 "user_id",
1221 "thread_id",
1222 "role",
1223 "message_id",
1224 "author_name",
1225 ] {
1226 let path = format!("$.{field}");
1227 let pos = args
1228 .iter()
1229 .position(|a| a == &path)
1230 .unwrap_or_else(|| panic!("missing schema field {field}"));
1231 assert_eq!(args[pos + 1], "AS");
1232 assert_eq!(args[pos + 2], field);
1233 assert_eq!(args[pos + 3], "TAG");
1234 }
1235 }
1236
1237 #[test]
1238 fn ft_create_args_rank_field_is_numeric_sortable() {
1239 let args = ft_create_args("context", "context_idx");
1240 let pos = args.iter().position(|a| a == "$.rank").unwrap();
1241 assert_eq!(args[pos + 1], "AS");
1242 assert_eq!(args[pos + 2], "rank");
1243 assert_eq!(args[pos + 3], "NUMERIC");
1244 assert_eq!(args[pos + 4], "SORTABLE");
1245 }
1246
1247 #[test]
1248 fn ft_create_args_uses_custom_key_prefix() {
1249 let args = ft_create_args("myapp", "myapp_idx");
1250 assert_eq!(args[0], "myapp_idx");
1251 assert_eq!(args[5], "myapp:entry:");
1252 }
1253
1254 #[test]
1259 fn build_ft_search_query_single_scope_field_and_tokens() {
1260 let scope = Scope {
1261 user_id: Some("u1".to_string()),
1262 ..Default::default()
1263 };
1264 let tokens = vec!["seattle".to_string()];
1265 assert_eq!(
1266 build_ft_search_query(&scope, &tokens),
1267 "@user_id:{u1} @content:(seattle)"
1268 );
1269 }
1270
1271 #[test]
1272 fn build_ft_search_query_ands_all_configured_scope_fields() {
1273 let scope = Scope {
1274 application_id: Some("app1".to_string()),
1275 agent_id: Some("agent1".to_string()),
1276 user_id: Some("u1".to_string()),
1277 thread_id: Some("t1".to_string()),
1278 };
1279 let query = build_ft_search_query(&scope, &[]);
1280 assert_eq!(
1281 query,
1282 "@application_id:{app1} @agent_id:{agent1} @user_id:{u1} @thread_id:{t1}"
1283 );
1284 }
1285
1286 #[test]
1287 fn build_ft_search_query_ors_multiple_tokens() {
1288 let scope = Scope {
1289 user_id: Some("u1".to_string()),
1290 ..Default::default()
1291 };
1292 let tokens = vec!["capital".to_string(), "france".to_string()];
1293 assert_eq!(
1294 build_ft_search_query(&scope, &tokens),
1295 "@user_id:{u1} @content:(capital|france)"
1296 );
1297 }
1298
1299 #[test]
1300 fn build_ft_search_query_escapes_special_characters_in_scope_values() {
1301 let scope = Scope {
1302 user_id: Some("user@example.com".to_string()),
1303 ..Default::default()
1304 };
1305 let query = build_ft_search_query(&scope, &[]);
1306 assert_eq!(query, "@user_id:{user\\@example\\.com}");
1307 }
1308
1309 #[test]
1310 fn build_ft_search_query_no_scope_no_tokens_is_wildcard() {
1311 assert_eq!(build_ft_search_query(&Scope::default(), &[]), "*");
1312 }
1313
1314 #[test]
1315 fn build_ft_search_query_no_tokens_omits_content_clause() {
1316 let scope = Scope {
1317 thread_id: Some("t1".to_string()),
1318 ..Default::default()
1319 };
1320 assert_eq!(build_ft_search_query(&scope, &[]), "@thread_id:{t1}");
1321 }
1322
1323 #[test]
1328 fn parse_ft_search_reply_extracts_entries_from_dollar_field() {
1329 let entry_json = serde_json::to_string(&entry("hello", 1)).unwrap();
1330 let reply = redis::Value::Array(vec![
1331 redis::Value::Int(1),
1332 redis::Value::BulkString(b"context:entry:abc".to_vec()),
1333 redis::Value::Array(vec![
1334 redis::Value::BulkString(b"$".to_vec()),
1335 redis::Value::BulkString(entry_json.into_bytes()),
1336 ]),
1337 ]);
1338 let parsed = parse_ft_search_reply(&reply);
1339 assert_eq!(parsed.len(), 1);
1340 assert_eq!(parsed[0].content, "hello");
1341 }
1342
1343 #[test]
1344 fn parse_ft_search_reply_preserves_order_across_multiple_docs() {
1345 let e1 = serde_json::to_string(&entry("first", 1)).unwrap();
1346 let e2 = serde_json::to_string(&entry("second", 2)).unwrap();
1347 let reply = redis::Value::Array(vec![
1348 redis::Value::Int(2),
1349 redis::Value::BulkString(b"k1".to_vec()),
1350 redis::Value::Array(vec![
1351 redis::Value::BulkString(b"$".to_vec()),
1352 redis::Value::BulkString(e1.into_bytes()),
1353 ]),
1354 redis::Value::BulkString(b"k2".to_vec()),
1355 redis::Value::Array(vec![
1356 redis::Value::BulkString(b"$".to_vec()),
1357 redis::Value::BulkString(e2.into_bytes()),
1358 ]),
1359 ]);
1360 let parsed = parse_ft_search_reply(&reply);
1361 assert_eq!(
1362 parsed
1363 .iter()
1364 .map(|e| e.content.as_str())
1365 .collect::<Vec<_>>(),
1366 vec!["first", "second"]
1367 );
1368 }
1369
1370 #[test]
1371 fn parse_ft_search_reply_empty_results() {
1372 let reply = redis::Value::Array(vec![redis::Value::Int(0)]);
1373 assert!(parse_ft_search_reply(&reply).is_empty());
1374 }
1375
1376 #[test]
1377 fn parse_ft_search_reply_non_array_top_level_yields_empty() {
1378 assert!(parse_ft_search_reply(&redis::Value::Nil).is_empty());
1379 }
1380
1381 #[test]
1382 fn parse_ft_search_reply_skips_malformed_json_without_failing() {
1383 let reply = redis::Value::Array(vec![
1384 redis::Value::Int(1),
1385 redis::Value::BulkString(b"k1".to_vec()),
1386 redis::Value::Array(vec![
1387 redis::Value::BulkString(b"$".to_vec()),
1388 redis::Value::BulkString(b"not json".to_vec()),
1389 ]),
1390 ]);
1391 assert!(parse_ft_search_reply(&reply).is_empty());
1392 }
1393
1394 #[test]
1395 fn parse_ft_search_reply_ignores_fields_other_than_dollar() {
1396 let reply = redis::Value::Array(vec![
1397 redis::Value::Int(1),
1398 redis::Value::BulkString(b"k1".to_vec()),
1399 redis::Value::Array(vec![
1400 redis::Value::BulkString(b"content".to_vec()),
1401 redis::Value::BulkString(b"hello".to_vec()),
1402 ]),
1403 ]);
1404 assert!(parse_ft_search_reply(&reply).is_empty());
1405 }
1406
1407 #[test]
1412 fn is_index_exists_error_matches_redisearch_error_text() {
1413 assert!(is_index_exists_error("Index already exists"));
1414 assert!(is_index_exists_error(
1415 "An error was signalled by the server - ResponseError: Index already exists"
1416 ));
1417 }
1418
1419 #[test]
1420 fn is_index_exists_error_case_insensitive() {
1421 assert!(is_index_exists_error("INDEX ALREADY EXISTS"));
1422 }
1423
1424 #[test]
1425 fn is_index_exists_error_rejects_unrelated_errors() {
1426 assert!(!is_index_exists_error("unknown command 'FT.CREATE'"));
1427 assert!(!is_index_exists_error("connection refused"));
1428 }
1429
1430 #[test]
1435 fn force_scan_fallback_defaults_to_false() {
1436 assert!(!provider().force_scan_fallback);
1437 }
1438
1439 #[test]
1440 fn with_force_scan_fallback_sets_flag() {
1441 assert!(
1442 provider()
1443 .with_force_scan_fallback(true)
1444 .force_scan_fallback
1445 );
1446 }
1447
1448 #[tokio::test]
1456 async fn before_run_sets_session_thread_id() {
1457 let p = provider().with_scope_to_per_operation_thread_id(true);
1458 let mut ctx = SessionContext::new(vec![]);
1459 ctx.session_id = Some("t1".to_string());
1460 p.before_run(&mut ctx).await.unwrap();
1461 assert_eq!(p.session_thread_id.lock().await.as_deref(), Some("t1"));
1462 }
1463
1464 #[tokio::test]
1465 async fn before_run_does_not_overwrite_existing_session_thread_id() {
1466 let p = provider().with_scope_to_per_operation_thread_id(true);
1467 let mut ctx = SessionContext::new(vec![]);
1468 ctx.session_id = Some("t1".to_string());
1469 p.before_run(&mut ctx).await.unwrap();
1470 let mut ctx = SessionContext::new(vec![]);
1471 ctx.session_id = Some("t1".to_string());
1472 p.before_run(&mut ctx).await.unwrap();
1473 assert_eq!(p.session_thread_id.lock().await.as_deref(), Some("t1"));
1474 }
1475
1476 #[tokio::test]
1477 async fn before_run_conflict_when_scoped() {
1478 let p = provider().with_scope_to_per_operation_thread_id(true);
1479 let mut ctx = SessionContext::new(vec![]);
1480 ctx.session_id = Some("t1".to_string());
1481 p.before_run(&mut ctx).await.unwrap();
1482 let mut ctx = SessionContext::new(vec![]);
1483 ctx.session_id = Some("t2".to_string());
1484 let err = p.before_run(&mut ctx).await.unwrap_err();
1485 assert!(err.to_string().contains("only be used with one thread"));
1486 }
1487
1488 #[tokio::test]
1489 async fn before_run_allows_none_session_id_repeatedly() {
1490 let p = provider().with_scope_to_per_operation_thread_id(true);
1491 let mut ctx = SessionContext::new(vec![]);
1492 p.before_run(&mut ctx).await.unwrap();
1493 let mut ctx = SessionContext::new(vec![]);
1494 p.before_run(&mut ctx).await.unwrap();
1495 let mut ctx = SessionContext::new(vec![]);
1496 ctx.session_id = Some("t1".to_string());
1497 p.before_run(&mut ctx).await.unwrap();
1498 assert_eq!(p.session_thread_id.lock().await.as_deref(), Some("t1"));
1499 }
1500
1501 #[tokio::test]
1502 async fn before_run_without_scoping_never_conflicts() {
1503 let p = provider(); let mut ctx = SessionContext::new(vec![]);
1505 ctx.session_id = Some("t1".to_string());
1506 p.before_run(&mut ctx).await.unwrap();
1507 let mut ctx = SessionContext::new(vec![]);
1509 ctx.session_id = Some("t2".to_string());
1510 p.before_run(&mut ctx).await.unwrap();
1511 }
1512
1513 #[tokio::test]
1518 async fn before_run_fails_without_scope_configured() {
1519 let p = RedisContextProvider::new("redis://127.0.0.1:6379/0").unwrap();
1520 let mut ctx = SessionContext::new(vec![Message::user("hi")]);
1521 let err = p.before_run(&mut ctx).await.unwrap_err();
1522 assert!(err.to_string().contains("At least one of the filters"));
1523 }
1524
1525 #[tokio::test]
1526 async fn after_run_fails_without_scope_configured() {
1527 let p = RedisContextProvider::new("redis://127.0.0.1:6379/0").unwrap();
1528 let err = p
1529 .after_run(&[Message::user("hi")], &[], None)
1530 .await
1531 .unwrap_err();
1532 assert!(err.to_string().contains("At least one of the filters"));
1533 }
1534
1535 }