Skip to main content

agent_framework_redis/
context_provider.rs

1//! A [`ContextProvider`] that stores and retrieves conversation memories in
2//! Redis, scoped by application/agent/user/thread id.
3//!
4//! Mirrors the *scoping and prompt-injection* behavior of the Python
5//! `agent_framework_redis.RedisProvider`: `after_run()` persists the
6//! request/response exchange as JSON entries tagged with the provider's
7//! scope, and `before_run()` retrieves matching entries and injects them into
8//! the conversation as a single `user`-role [`Message`] prefixed by
9//! [`DEFAULT_CONTEXT_PROMPT`] (same default text as Python's
10//! `ContextProvider.DEFAULT_CONTEXT_PROMPT`).
11//!
12//! # Divergence from Python: BM25 full-text via RediSearch, but no vector search
13//!
14//! Python's `RedisProvider` is built on
15//! [redisvl](https://github.com/redis/redis-vl-python) and RediSearch: it
16//! maintains a `FT.CREATE`d index with `TAG`/`TEXT`/`VECTOR` fields, and
17//! `before_run()` runs a server-side `TextQuery` (BM25 full-text) or
18//! `HybridQuery` (BM25 + KNN vector similarity, when a vectorizer is
19//! configured) against that index.
20//!
21//! RediSearch is a separate Redis module — bundled with **Redis Stack** but
22//! not with plain open-source `redis-server` — so [`RedisContextProvider`]
23//! detects it at runtime (once per provider instance, cached — see
24//! `FT._LIST`/`with_force_scan_fallback`) and switches behavior accordingly:
25//!
26//! - **RediSearch available** (Redis Stack): each memory is written with
27//!   `JSON.SET {key_prefix}:entry:{uuid} $ <entry>` instead of a plain
28//!   `SET`, so it's visible to an `FT.CREATE ... ON JSON PREFIX 1
29//!   {key_prefix}:entry: SCHEMA ...` index (created lazily, once, and
30//!   tolerant of a concurrent creator via Redis's own "Index already
31//!   exists" error) covering every `MemoryEntry` field: `content` is
32//!   `TEXT` (BM25-scored full-text); `application_id`/`agent_id`/`user_id`/
33//!   `thread_id`/`role`/`message_id`/`author_name` are `TAG` (exact-match
34//!   scope filtering); `rank` is `NUMERIC SORTABLE`. `before_run()` runs a
35//!   single `FT.SEARCH` combining ANDed `TAG` filters for the configured
36//!   scope with an ORed `@content:(...)` clause over the same lowercased,
37//!   stopword-filtered tokens the SCAN path uses (`tokenize_query`),
38//!   `LIMIT`ed to the configured [`RedisContextProvider::with_limit`], and
39//!   ranked by RediSearch's own default scorer (a BM25 variant — no
40//!   explicit `SCORER` argument is sent, since the exact literal scorer
41//!   name has changed across RediSearch versions — `BM25` was renamed
42//!   `BM25STD` in Redis Open Source 8.4 — while the *default* scorer has
43//!   been BM25-family since RediSearch 1.x, so omitting `SCORER` gets BM25
44//!   scoring portably). All caller-supplied scope values and query tokens
45//!   are backslash-escaped against RediSearch's reserved query-syntax
46//!   characters before being interpolated into the query string
47//!   (`escape_redisearch`), so they can never be misinterpreted as query
48//!   operators.
49//! - **RediSearch unavailable** (plain `redis-server`), or when
50//!   [`RedisContextProvider::with_force_scan_fallback`] is set: the
51//!   original SCAN-based behavior, documented in detail below, is used
52//!   unchanged.
53//!
54//! Vector/hybrid (KNN) search is **still not ported** — that remains this
55//! crate's one substantive gap versus Python, since it requires an
56//! embedding model/vectorizer this crate has no opinion on. Bring your own
57//! vector store (or embed client-side and extend this provider) if you need
58//! semantic recall; BM25 full-text (when Redis Stack is available) or
59//! token-match (otherwise) is what you get here.
60//!
61//! Because RediSearch requires documents to be stored as native JSON
62//! (`JSON.SET`) rather than opaque strings (`SET`) to be indexable, the two
63//! storage encodings are not cross-readable: entries written while
64//! RediSearch was available are invisible to the plain `SCAN`+`MGET`
65//! fallback (which only sees string-typed keys), and vice versa. This only
66//! matters if a deployment's effective RediSearch usage *changes* after
67//! memories already exist (e.g. migrating from OSS Redis to Redis Stack, or
68//! flipping [`RedisContextProvider::with_force_scan_fallback`] mid-flight)
69//! — capability is detected once per provider instance and does not
70//! retroactively reconcile entries written under the other encoding.
71//!
72//! ## The SCAN fallback (RediSearch unavailable)
73//!
74//! 1. Stores each memory as its own key `{key_prefix}:entry:{uuid}` holding
75//!    a JSON blob (content, role, scope fields, a client-assigned recency
76//!    rank) — no index, no schema, no `FT.CREATE`/`overwrite_index`.
77//! 2. On `before_run()`, enumerates candidates with `SCAN MATCH
78//!    {key_prefix}:entry:*` (cursor-based, non-blocking — never `KEYS`),
79//!    `MGET`s their values, and **filters entirely client-side**:
80//!    - scope filter: exact-match AND over whichever of
81//!      application/agent/user/thread id are configured (same semantics as
82//!      Python's `Tag(k) == v` conjunction), then
83//!    - *optional* naive full-text filter: if the query text is non-empty,
84//!      split it into lowercased alphanumeric tokens, drop a small built-in
85//!      stopword list (`the`, `is`, `of`, ...), and keep only entries whose
86//!      content *contains* (substring, not word-boundary) at least one
87//!      remaining token — a keyword check, not BM25 ranking or embeddings,
88//!      then
89//!    - sort by recency (most recent first) and take the configured
90//!      `limit` (default 10, matching Python's `num_results` default).
91//!
92//! There is no relevance ranking beyond "did a token match" plus recency,
93//! no stemming, and — because every fallback `before_run()` call does a full
94//! `SCAN` over the prefix — retrieval is `O(entries under key_prefix)`
95//! rather than `O(log n)`/index-accelerated. This is adequate for modest
96//! memory volumes (demos, tests, small deployments); reach for Redis Stack
97//! (giving you the `FT.SEARCH` path above) for anything larger.
98//!
99//! `application_id` participates in the scope filter here — unlike in the
100//! sibling `agent-framework-mem0` crate's `Mem0Provider`, where it is
101//! write-only metadata — matching the Python `RedisProvider`, which
102//! includes `application_id` in its combined `Tag` filter for both reads
103//! and writes.
104
105use 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
117/// Default Redis key prefix, matching the Python provider's default `prefix`.
118pub const DEFAULT_KEY_PREFIX: &str = "context";
119
120/// Default context-injection header, byte-for-byte identical to Python's
121/// `agent_framework.ContextProvider.DEFAULT_CONTEXT_PROMPT`.
122pub const DEFAULT_CONTEXT_PROMPT: &str =
123    "## Memories\nConsider the following memories when answering user questions:";
124
125/// Default number of memories returned by `before_run()`, matching Python's
126/// `RedisProvider._redis_search(..., num_results=10)` default.
127pub const DEFAULT_LIMIT: usize = 10;
128
129/// The wire format for one stored memory: written to (and read back from)
130/// either a plain string key (`SET`, SCAN-fallback path) or a RedisJSON
131/// document (`JSON.SET`, RediSearch path) at `{key_prefix}:entry:{uuid}`.
132/// Private to this module — the storage simplification documented above is
133/// entirely our own, so there is no Python shape to mirror here (contrast
134/// with [`crate::RedisChatMessageStore`], whose per-message JSON *is* meant
135/// to be wire-compatible-in-spirit with the Python store). Every field here
136/// has a corresponding `SCHEMA` entry in [`ft_create_args`].
137#[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    /// Client-assigned recency rank: `unix_millis * 1000 + index_in_batch`.
154    /// Higher is more recent. Avoids a round trip through a Redis-side
155    /// counter while still giving messages added in the same `after_run()`
156    /// call (which may share a millisecond) a stable relative order. Used
157    /// to sort SCAN-fallback hits; carried into the RediSearch schema too
158    /// (`NUMERIC SORTABLE`) for potential future use, though `before_run()`'s
159    /// RediSearch path currently sorts by relevance (BM25), not rank — see
160    /// the module docs.
161    rank: i64,
162}
163
164/// The scope a provider instance is configured with. `None` fields are
165/// wildcards (unconstrained); `Some` fields must match exactly. Mirrors the
166/// ANDed `Tag(k) == v for k, v in filters.items() if v` behavior of
167/// Python's `RedisProvider._build_filter_from_dict`.
168#[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
198/// Extremely common English function words, excluded from the query token
199/// set before matching. Without this, a query like "What is the capital of
200/// France?" would token-match almost any stored memory purely because both
201/// share the word "the" — full-blown stopword lists / stemming are exactly
202/// the machinery a real search engine (or RediSearch) provides and this
203/// crate does not; this is the minimum needed to keep "simple" from also
204/// meaning "matches everything". Also used, unmodified, to build the
205/// RediSearch full-text query clause (`tokenize_query`) so both retrieval
206/// paths agree on what counts as a "meaningful" token.
207const 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
214/// Lowercase, alphanumeric-only tokenization with the [`STOPWORDS`] filter
215/// applied. Shared by [`select_recent`]'s client-side "does any token
216/// appear" SCAN-fallback filter and [`build_ft_search_query`]'s
217/// `@content:(tok1|tok2|...)` clause for the RediSearch path, so the two
218/// retrieval paths agree on what a "matching token" means even though one
219/// does a substring check and the other lets RediSearch's own indexer/BM25
220/// scorer do the matching.
221fn 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
230/// Pure selection logic — no I/O, fully unit-testable: scope-filter, then
231/// (if `query` is non-empty) keep only entries with at least one matching
232/// lowercase, non-stopword token, then sort by recency and take the top
233/// `limit`. Used only by the SCAN fallback; the RediSearch path
234/// (`RedisContextProvider::ft_search`) does the equivalent filtering
235/// server-side.
236fn 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
264/// Build the single `user`-role memory-injection message for `before_run()`
265/// to push onto `SessionContext::messages`, or `None` when there is nothing
266/// to inject (mirrors the old `Context::default()` "no-op" case).
267fn 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
285// region: RediSearch (FT.*) support — pure, hermetically-testable pieces
286
287/// Characters RediSearch's query parser treats as syntactically special
288/// (per RediSearch's documented query-escaping rules: commas, punctuation,
289/// brackets, quotes, and whitespace break tokenization; `|` is the OR
290/// operator; `\` is the escape character itself). Backslash-escaping every
291/// occurrence in caller-supplied text (TAG values, query tokens) before
292/// interpolating it into a query string guarantees it is matched literally
293/// rather than parsed as query syntax — the "escape query text safely"
294/// requirement for the RediSearch upgrade.
295const REDISEARCH_SPECIAL_CHARS: &[char] = &[
296    ',', '.', '<', '>', '{', '}', '[', ']', '"', '\'', ':', ';', '!', '@', '#', '$', '%', '^', '&',
297    '*', '(', ')', '-', '+', '=', '~', '|', ' ', '\\',
298];
299
300/// Backslash-escape every RediSearch-reserved character in `value` (see
301/// [`REDISEARCH_SPECIAL_CHARS`]).
302fn 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
313/// Build the `FT.CREATE {index_name} ...` argument list (everything after
314/// the command name itself), managing one RediSearch index per key prefix
315/// over the JSON documents written to `{key_prefix}:entry:*`. `ON JSON`
316/// requires each entry to be written with `JSON.SET` rather than a plain
317/// `SET` of serialized text — see the module docs for how writes switch
318/// based on detected capability. The schema mirrors every field of
319/// `MemoryEntry`: `content` is the only full-text (`TEXT`) field
320/// (BM25-scored); the scope/identity fields are exact-match `TAG`s; `rank`
321/// is `NUMERIC SORTABLE`.
322fn 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
363/// Build the `FT.SEARCH` query-string argument (everything the caller
364/// supplies after `FT.SEARCH {index}`) for `scope` (ANDed exact-match
365/// `TAG` filters, mirroring [`Scope::matches`]) combined with an OR of
366/// `tokens` against the `content` field (mirrors the OR/"any token"
367/// semantics of the SCAN-fallback's [`select_recent`]). Every scope value
368/// and token is escaped with [`escape_redisearch`] first. Falls back to
369/// `"*"` (match everything) only if both `scope` and `tokens` are empty —
370/// unreachable in practice, since `before_run()` requires non-empty `tokens`
371/// and `validate_filters()` requires a non-empty `scope` before this is
372/// ever called, but kept total rather than partial.
373fn 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
402/// Parse a raw `FT.SEARCH ... RETURN 1 $` reply into [`MemoryEntry`]
403/// values, in the order RediSearch returned them (its own relevance
404/// ranking — no `SORTBY` is sent, so this is BM25-descending). RESP2 shape:
405/// `[total, doc_id_1, [field_1, value_1, ...], doc_id_2, ...]`; with
406/// `RETURN 1 $` each per-doc field array is exactly `["$", "<json>"]`,
407/// where `<json>` is the complete original document (RediSearch's
408/// documented behavior for returning the JSON root of an `ON JSON`-indexed
409/// document). Anything that doesn't match this shape (unexpected reply
410/// type, a `$` value that fails to parse as [`MemoryEntry`]) is skipped
411/// rather than failing the whole call, mirroring
412/// [`RedisContextProvider::scan_entries`]'s leniency for the SCAN fallback.
413fn 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; // items[0] is the total result count.
419    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
442/// Whether an `FT.CREATE` error means "this index already exists" — safe to
443/// swallow, since [`RedisContextProvider::ensure_index`] creates
444/// optimistically (no separate existence probe first) and multiple
445/// provider instances/processes sharing a `key_prefix` will race to create
446/// the same index. RediSearch's documented error text for this case is the
447/// literal string `Index already exists`.
448fn is_index_exists_error(message: &str) -> bool {
449    message.to_lowercase().contains("index already exists")
450}
451
452/// Probe whether the connected server has RediSearch loaded, via `FT._LIST`
453/// (lists index names; succeeds — possibly with an empty array — iff the
454/// module is present). Never propagates an error: any failure (unknown
455/// command on plain/OSS Redis, a transient connection error, ...) is
456/// treated as "not available" so callers always get a definite yes/no to
457/// cache.
458async 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
465// endregion
466
467/// Redis-backed [`ContextProvider`]: recency-based memory storage/retrieval
468/// scoped by application/agent/user/thread id, upgraded to a real
469/// `FT.SEARCH` BM25 full-text index when the connected server supports
470/// RediSearch (Redis Stack). See the module docs for the RediSearch-vs-SCAN
471/// behavior and the remaining vector-search divergence from Python's
472/// `RedisProvider`.
473///
474/// ```no_run
475/// use agent_framework_redis::RedisContextProvider;
476/// use agent_framework_core::memory::{ContextProvider, SessionContext};
477/// use agent_framework_core::types::Message;
478///
479/// # async fn demo() -> agent_framework_core::error::Result<()> {
480/// let provider = RedisContextProvider::new("redis://127.0.0.1:6379")?
481///     .with_user_id("user-42")
482///     .with_limit(5);
483///
484/// let request = vec![Message::user("I love hiking in the Cascades")];
485/// provider.after_run(&request, &[], None).await?;
486///
487/// let mut ctx = SessionContext::new(vec![Message::user("Any outdoor hobbies?")]);
488/// provider.before_run(&mut ctx).await?;
489/// # Ok(())
490/// # }
491/// ```
492pub 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    /// The per-operation thread id, captured from `SessionContext::session_id`
503    /// on `before_run()` (the agent threads its scoping id through
504    /// `SessionContext` — see [`agent_framework_core::memory::SessionContext`]).
505    /// Cached here (rather than only living on the stack) so `after_run()` —
506    /// which the trait does not pass a `SessionContext` to — can still scope
507    /// its storage write to the same thread. Only consulted when
508    /// `scope_to_per_operation_thread_id` is set.
509    session_thread_id: Mutex<Option<String>>,
510    /// When `true`, always use the SCAN fallback even if RediSearch is
511    /// available (builder: [`Self::with_force_scan_fallback`]).
512    force_scan_fallback: bool,
513    /// Cached result of the one-time `FT._LIST` capability probe (skipped
514    /// entirely when `force_scan_fallback` is set).
515    search_capability: OnceCell<bool>,
516    /// Set once this provider's RediSearch index has been created (or
517    /// confirmed to already exist).
518    index_ready: OnceCell<()>,
519}
520
521impl RedisContextProvider {
522    /// Create a provider for `redis_url` with no scope configured yet (at
523    /// least one of application/agent/user/thread id must be set via the
524    /// builder methods before `before_run`/`after_run` are called).
525    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    /// Namespace Redis keys under `key_prefix` (builder style). Defaults to
546    /// `"context"`.
547    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    /// Scope memories to an application id (builder style).
553    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    /// Scope memories to an agent id (builder style).
559    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    /// Scope memories to a user id (builder style).
565    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    /// Scope memories to a thread id (builder style).
571    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    /// When `true`, the thread id used for scoping is captured from the
577    /// `session_id` of the first [`SessionContext`] seen by
578    /// [`ContextProvider::before_run`] instead of the static `thread_id`
579    /// above, and a conflicting `session_id` on a later call is an error
580    /// (builder style).
581    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    /// Override the header prepended to injected memories (builder style).
587    /// Defaults to [`DEFAULT_CONTEXT_PROMPT`].
588    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    /// Maximum memories returned per `before_run()` call (builder style).
594    /// Defaults to [`DEFAULT_LIMIT`].
595    pub fn with_limit(mut self, limit: usize) -> Self {
596        self.limit = limit;
597        self
598    }
599
600    /// Force the plain-`SCAN` retrieval path even when the connected server
601    /// supports RediSearch (builder style). `false` by default: RediSearch
602    /// is detected and used automatically. Useful for tests, for pinning
603    /// behavior during a Redis Stack rollout, or for working around an
604    /// operational RediSearch problem without a code change. See the
605    /// module docs for why entries written under one path are not visible
606    /// via the other.
607    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    /// Record `session_id` (from `SessionContext::session_id`, on
635    /// `before_run()`) as this provider's per-operation scoping thread id.
636    /// Port of the old `ContextProvider::thread_created` hook, which is no
637    /// longer part of the trait: the agent now threads the scoping id
638    /// through `SessionContext` instead of a separate callback, so this is
639    /// invoked directly from `before_run()`. Same semantics as the old
640    /// method: the first non-`None` id seen wins, and — only when
641    /// [`Self::with_scope_to_per_operation_thread_id`] is set — a later call
642    /// with a *different* non-`None` id is a configuration error, since this
643    /// provider can only be scoped to a single thread at a time.
644    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    /// RediSearch index name for this provider's `key_prefix` — one index
679    /// per prefix, so distinct [`Self::with_key_prefix`] configurations
680    /// never collide on the same server.
681    fn index_name(&self) -> String {
682        format!("{}_idx", self.key_prefix)
683    }
684
685    /// `SCAN MATCH {key_prefix}:entry:*`, then `MGET` the matched keys and
686    /// parse each JSON value. Keys that vanish between `SCAN` and `MGET`
687    /// (e.g. concurrently cleared), whose value fails to parse, or whose
688    /// value isn't a plain string (e.g. a RedisJSON document written via
689    /// the RediSearch path — see the module docs) are silently skipped
690    /// rather than failing the whole call.
691    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    /// Whether this call should use RediSearch: `false` immediately if
716    /// [`Self::with_force_scan_fallback`] was set (no network probe at
717    /// all — forcing fallback must work even against a server that would
718    /// otherwise answer `FT._LIST`); otherwise the result of
719    /// [`probe_redisearch`], cached for the lifetime of this provider
720    /// after the first call.
721    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    /// Idempotently create this provider's RediSearch index (`FT.CREATE`,
732    /// tolerating "already exists"), memoized after the first successful
733    /// attempt so steady-state calls skip the round trip entirely. Only
734    /// called once [`Self::use_redisearch`] has confirmed RediSearch is
735    /// available.
736    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    /// Run `FT.SEARCH` for `scope`+`tokens`, returning matching entries in
755    /// the order RediSearch returns them (its default relevance scoring,
756    /// descending — see the module docs for why no explicit `SCORER` is
757    /// sent). Ensures the index exists first (see [`Self::ensure_index`]).
758    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        // Replaces the old `thread_created` hook: the agent now threads the
789        // per-operation scoping id through `SessionContext::session_id`
790        // rather than a separate callback, so we capture it here, on every
791        // `before_run()` call, exactly as `thread_created` used to be called
792        // on every thread-creation event.
793        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            // Python's RedisProvider unconditionally calls `_redis_search`,
805            // which raises on empty text; we instead treat "nothing to
806            // search for" as "no memories" — see module docs.
807            return Ok(());
808        }
809
810        let scope = self.scope().await;
811        if scope.is_empty() {
812            // Unreachable given `validate_filters` above (it guarantees at
813            // least one scope field), but keep `Scope` honest in isolation.
814            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            // RediSearch (ON JSON) can only index native RedisJSON
884            // documents, not opaque strings — see the module docs.
885            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    // region: key construction
928
929    #[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    // endregion
957
958    // region: validate_filters
959
960    #[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    // endregion
976
977    // region: Scope matching (pure, no server)
978
979    #[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        // agent_id/application_id/thread_id are None on both scope and
992        // entry, so only user_id needs to match.
993        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    // endregion
1018
1019    // region: tokenize_query (pure, no server)
1020
1021    #[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    // endregion
1043
1044    // region: select_recent (pure, no server)
1045
1046    #[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    // endregion
1115
1116    // region: format_context_message
1117
1118    #[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    // endregion
1134
1135    // region: is_storable_role
1136
1137    #[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    // endregion
1150
1151    // region: escape_redisearch (pure, no server)
1152
1153    #[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    // endregion
1190
1191    // region: ft_create_args (pure, no server)
1192
1193    #[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    // endregion
1255
1256    // region: build_ft_search_query (pure, no server)
1257
1258    #[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    // endregion
1324
1325    // region: parse_ft_search_reply (pure, no server — hand-built redis::Value fixtures)
1326
1327    #[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    // endregion
1408
1409    // region: is_index_exists_error (pure, no server)
1410
1411    #[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    // endregion
1431
1432    // region: with_force_scan_fallback builder (pure, no server)
1433
1434    #[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    // endregion
1449
1450    // region: before_run() session_id scoping (async, no server: empty
1451    // input_messages makes before_run() return before any I/O, right after
1452    // recording the session thread id — same "pure Mutex state" coverage the
1453    // old thread_created tests had.
1454
1455    #[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(); // scope_to_per_operation_thread_id defaults to false
1504        let mut ctx = SessionContext::new(vec![]);
1505        ctx.session_id = Some("t1".to_string());
1506        p.before_run(&mut ctx).await.unwrap();
1507        // No conflict error even though the id changes, since scoping is off.
1508        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    // endregion
1514
1515    // region: before_run()/after_run() input validation (async, no server: fails before any I/O)
1516
1517    #[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    // endregion
1536}