Skip to main content

agent_framework_redis/
chat_message_store.rs

1//! A Redis-backed [`HistoryProvider`]: conversation history stored in a
2//! Redis `LIST`.
3//!
4//! Mirrors the Python `agent_framework_redis.RedisChatMessageStore`: each
5//! session owns one Redis list at key `{key_prefix}:{session_id}`, messages
6//! are appended with `RPUSH` (chronological order, oldest first), read back
7//! with `LRANGE`, and — when `max_messages` is configured — trimmed to the
8//! most recent N entries with `LTRIM` after every write. Each list element
9//! is one message, JSON-serialized with `serde_json` (equivalent to the
10//! Python store's `Message.to_json()` / `Message.from_json()` round trip).
11//!
12//! Upstream folded the standalone `ChatMessageStore` abstraction into
13//! [`HistoryProvider`] — a
14//! `ContextProvider` that prepends its stored messages ahead of a run
15//! (`before_run`) and records the run's request + response messages after a
16//! successful run (`after_run`). [`RedisChatMessageStore`] follows suit
17//! directly (rather than wrapping an
18//! [`InMemoryHistoryProvider`](agent_framework_core::history::InMemoryHistoryProvider))
19//! so history actually persists to Redis, the same way
20//! [`FileHistoryProvider`](agent_framework_core::history::FileHistoryProvider)
21//! persists to disk.
22
23use async_trait::async_trait;
24use redis::AsyncCommands;
25use uuid::Uuid;
26
27use agent_framework_core::error::{Error, Result};
28use agent_framework_core::history::HistoryProvider;
29use agent_framework_core::memory::{ContextProvider, SessionContext};
30use agent_framework_core::types::Message;
31
32use crate::internal::{map_redis_err, LazyConnection};
33
34/// Default Redis key prefix, matching the Python store's default.
35pub const DEFAULT_KEY_PREFIX: &str = "chat_messages";
36
37/// Redis-backed [`HistoryProvider`]: one Redis `LIST` per session,
38/// JSON-serialized messages, optional automatic trimming.
39///
40/// ```no_run
41/// use agent_framework_redis::RedisChatMessageStore;
42///
43/// # async fn demo() -> agent_framework_core::error::Result<()> {
44/// let store = RedisChatMessageStore::new("redis://127.0.0.1:6379", None)?
45///     .with_key_prefix("my_app")
46///     .with_max_messages(100);
47///
48/// store.add_messages(vec![agent_framework_core::types::Message::user("hello")]).await?;
49/// let history = store.list_messages().await?;
50/// println!("{} messages for session {}", history.len(), store.session_id());
51/// # Ok(())
52/// # }
53/// ```
54pub struct RedisChatMessageStore {
55    conn: LazyConnection,
56    redis_url: String,
57    session_id: String,
58    key_prefix: String,
59    max_messages: Option<usize>,
60}
61
62impl RedisChatMessageStore {
63    /// Create a store for `redis_url`, optionally pinned to an existing
64    /// `session_id`. When `session_id` is `None` a fresh id is generated as
65    /// `thread_{uuid}`, matching the Python store's `f"thread_{uuid4()}"`.
66    ///
67    /// The connection to Redis is *not* established here; only the URL is
68    /// parsed. Errors if `redis_url` cannot be parsed as a Redis connection
69    /// string.
70    pub fn new(redis_url: impl Into<String>, session_id: Option<String>) -> Result<Self> {
71        let redis_url = redis_url.into();
72        let conn = LazyConnection::open(&redis_url)?;
73        Ok(Self {
74            conn,
75            redis_url,
76            session_id: session_id.unwrap_or_else(|| format!("thread_{}", Uuid::new_v4())),
77            key_prefix: DEFAULT_KEY_PREFIX.to_string(),
78            max_messages: None,
79        })
80    }
81
82    /// Namespace Redis keys under `key_prefix` (builder style). Defaults to
83    /// `"chat_messages"`.
84    pub fn with_key_prefix(mut self, key_prefix: impl Into<String>) -> Self {
85        self.key_prefix = key_prefix.into();
86        self
87    }
88
89    /// Automatically trim the list to the most recent `max_messages`
90    /// entries after every `add_messages` call (builder style).
91    pub fn with_max_messages(mut self, max_messages: usize) -> Self {
92        self.max_messages = Some(max_messages);
93        self
94    }
95
96    /// This store's session id (auto-generated if not supplied to [`Self::new`]).
97    pub fn session_id(&self) -> &str {
98        &self.session_id
99    }
100
101    /// The configured key prefix.
102    pub fn key_prefix(&self) -> &str {
103        &self.key_prefix
104    }
105
106    /// The configured message limit, if any.
107    pub fn max_messages(&self) -> Option<usize> {
108        self.max_messages
109    }
110
111    /// The Redis key holding this session's messages: `{key_prefix}:{session_id}`.
112    pub fn redis_key(&self) -> String {
113        format!("{}:{}", self.key_prefix, self.session_id)
114    }
115
116    /// Remove all messages for this session (`DEL` on [`Self::redis_key`]).
117    pub async fn clear(&self) -> Result<()> {
118        let mut conn = self.conn.get().await?;
119        let _: () = conn.del(self.redis_key()).await.map_err(map_redis_err)?;
120        Ok(())
121    }
122
123    /// Ping the Redis server, returning `true` on success. Equivalent to the
124    /// Python store's `ping()` convenience method.
125    pub async fn ping(&self) -> bool {
126        let Ok(mut conn) = self.conn.get().await else {
127            return false;
128        };
129        redis::cmd("PING")
130            .query_async::<String>(&mut conn)
131            .await
132            .is_ok()
133    }
134
135    /// The stored messages, in chronological order (`LRANGE 0 -1`).
136    pub async fn list_messages(&self) -> Result<Vec<Message>> {
137        let mut conn = self.conn.get().await?;
138        let raw: Vec<String> = conn
139            .lrange(self.redis_key(), 0, -1)
140            .await
141            .map_err(map_redis_err)?;
142        raw.iter().map(|s| Self::deserialize_message(s)).collect()
143    }
144
145    /// Append `messages` (`RPUSH`, chronological order), then trim to
146    /// [`Self::max_messages`] via `LTRIM` when configured. A no-op for an
147    /// empty `messages`.
148    pub async fn add_messages(&self, messages: Vec<Message>) -> Result<()> {
149        if messages.is_empty() {
150            return Ok(());
151        }
152        let mut conn = self.conn.get().await?;
153        let key = self.redis_key();
154
155        // Atomic batch append, mirroring the Python store's
156        // `pipeline(transaction=True)` + repeated RPUSH.
157        let mut pipe = redis::pipe();
158        pipe.atomic();
159        for message in &messages {
160            let payload = Self::serialize_message(message)?;
161            pipe.rpush(&key, payload);
162        }
163        let _: () = pipe.query_async(&mut conn).await.map_err(map_redis_err)?;
164
165        if let Some(max) = self.max_messages {
166            let len: usize = conn.llen(&key).await.map_err(map_redis_err)?;
167            if len > max {
168                let _: () = conn
169                    .ltrim(&key, -(max as isize), -1)
170                    .await
171                    .map_err(map_redis_err)?;
172            }
173        }
174        Ok(())
175    }
176
177    /// Serialize this store's *configuration* (session id, Redis URL, key
178    /// prefix, message limit) rather than the message contents — Redis
179    /// already persists the messages durably, so only the pointer back to
180    /// them needs to survive. This mirrors the Python store's `serialize()`
181    /// / `RedisStoreState`, including the `"type"` discriminator field. No
182    /// I/O is involved, so — like
183    /// [`AgentSession::to_dict`](agent_framework_core::session::AgentSession::to_dict) —
184    /// this is a plain, synchronous call.
185    pub fn to_dict(&self) -> serde_json::Value {
186        serde_json::json!({
187            "type": "redis_store_state",
188            "session_id": self.session_id,
189            "redis_url": self.redis_url,
190            "key_prefix": self.key_prefix,
191            "max_messages": self.max_messages,
192        })
193    }
194
195    /// Reconstruct a store from a value previously produced by
196    /// [`Self::to_dict`].
197    pub fn from_dict(state: &serde_json::Value) -> Result<Self> {
198        let session_id = state
199            .get("session_id")
200            .and_then(serde_json::Value::as_str)
201            .ok_or_else(|| Error::Configuration("state is missing 'session_id'".into()))?
202            .to_string();
203        let redis_url = state
204            .get("redis_url")
205            .and_then(serde_json::Value::as_str)
206            .ok_or_else(|| Error::Configuration("state is missing 'redis_url'".into()))?
207            .to_string();
208        let key_prefix = state
209            .get("key_prefix")
210            .and_then(serde_json::Value::as_str)
211            .unwrap_or(DEFAULT_KEY_PREFIX)
212            .to_string();
213        let max_messages = state
214            .get("max_messages")
215            .and_then(serde_json::Value::as_u64)
216            .map(|v| v as usize);
217
218        let mut store = Self::new(redis_url, Some(session_id))?;
219        store.key_prefix = key_prefix;
220        store.max_messages = max_messages;
221        Ok(store)
222    }
223
224    fn serialize_message(message: &Message) -> Result<String> {
225        Ok(serde_json::to_string(message)?)
226    }
227
228    fn deserialize_message(data: &str) -> Result<Message> {
229        Ok(serde_json::from_str(data)?)
230    }
231}
232
233#[async_trait]
234impl ContextProvider for RedisChatMessageStore {
235    async fn before_run(&self, ctx: &mut SessionContext) -> Result<()> {
236        let stored = self.list_messages().await?;
237        let existing = std::mem::take(&mut ctx.messages);
238        ctx.messages = stored.into_iter().chain(existing).collect();
239        Ok(())
240    }
241
242    async fn after_run(
243        &self,
244        request_messages: &[Message],
245        response_messages: &[Message],
246        error: Option<&Error>,
247    ) -> Result<()> {
248        if error.is_none() {
249            let mut combined = Vec::with_capacity(request_messages.len() + response_messages.len());
250            combined.extend(request_messages.iter().cloned());
251            combined.extend(response_messages.iter().cloned());
252            self.add_messages(combined).await?;
253        }
254        Ok(())
255    }
256
257    fn is_history_provider(&self) -> bool {
258        true
259    }
260}
261
262impl HistoryProvider for RedisChatMessageStore {}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use agent_framework_core::types::{Content, Role, TextContent};
268    use std::collections::HashMap;
269
270    fn store(session_id: &str) -> RedisChatMessageStore {
271        // `redis://.../0` parses without touching the network — safe to
272        // construct in hermetic unit tests.
273        RedisChatMessageStore::new("redis://127.0.0.1:6379/0", Some(session_id.to_string()))
274            .expect("valid redis url")
275    }
276
277    // region: key construction
278
279    #[test]
280    fn redis_key_uses_default_prefix() {
281        let s = store("t1");
282        assert_eq!(s.redis_key(), "chat_messages:t1");
283    }
284
285    #[test]
286    fn redis_key_uses_custom_prefix() {
287        let s = store("t1").with_key_prefix("custom_messages");
288        assert_eq!(s.redis_key(), "custom_messages:t1");
289    }
290
291    #[test]
292    fn session_id_auto_generated_when_absent() {
293        let s = RedisChatMessageStore::new("redis://127.0.0.1:6379/0", None).unwrap();
294        assert!(s.session_id().starts_with("thread_"));
295        // "thread_" (7) + a UUIDv4 (36) = 43 chars.
296        assert!(s.session_id().len() > 10);
297        // Two auto-generated stores must not collide.
298        let s2 = RedisChatMessageStore::new("redis://127.0.0.1:6379/0", None).unwrap();
299        assert_ne!(s.session_id(), s2.session_id());
300    }
301
302    #[test]
303    fn explicit_session_id_is_preserved() {
304        let s = store("user123_session456");
305        assert_eq!(s.session_id(), "user123_session456");
306        assert_eq!(s.redis_key(), "chat_messages:user123_session456");
307    }
308
309    #[test]
310    fn max_messages_defaults_to_none() {
311        let s = store("t1");
312        assert_eq!(s.max_messages(), None);
313    }
314
315    #[test]
316    fn with_max_messages_sets_limit() {
317        let s = store("t1").with_max_messages(100);
318        assert_eq!(s.max_messages(), Some(100));
319    }
320
321    #[test]
322    fn invalid_redis_url_is_rejected() {
323        let result = RedisChatMessageStore::new("not-a-redis-url", None);
324        assert!(result.is_err());
325    }
326
327    // endregion
328
329    // region: message JSON round trip (no server required)
330
331    #[test]
332    fn message_serialization_roundtrip_simple() {
333        let message = Message::new(Role::user(), "Hello").with_author("tester");
334        let serialized = RedisChatMessageStore::serialize_message(&message).unwrap();
335        assert!(serialized.contains("Hello"));
336        let deserialized = RedisChatMessageStore::deserialize_message(&serialized).unwrap();
337        assert_eq!(deserialized.role, message.role);
338        assert_eq!(deserialized.text(), "Hello");
339        assert_eq!(deserialized.author_name.as_deref(), Some("tester"));
340    }
341
342    #[test]
343    fn message_serialization_roundtrip_complex_content() {
344        let mut additional_properties = HashMap::new();
345        additional_properties.insert("metadata".to_string(), serde_json::json!("test"));
346        let message = Message {
347            role: Role::assistant(),
348            contents: vec![
349                Content::Text(TextContent::new("Hello")),
350                Content::Text(TextContent::new("World")),
351            ],
352            author_name: Some("TestBot".to_string()),
353            message_id: Some("complex_msg".to_string()),
354            additional_properties,
355        };
356
357        let serialized = RedisChatMessageStore::serialize_message(&message).unwrap();
358        let deserialized = RedisChatMessageStore::deserialize_message(&serialized).unwrap();
359
360        assert_eq!(deserialized.role, Role::assistant());
361        assert_eq!(deserialized.text(), "Hello World");
362        assert_eq!(deserialized.author_name.as_deref(), Some("TestBot"));
363        assert_eq!(deserialized.message_id.as_deref(), Some("complex_msg"));
364        assert_eq!(
365            deserialized.additional_properties.get("metadata"),
366            Some(&serde_json::json!("test"))
367        );
368    }
369
370    #[test]
371    fn deserialize_rejects_malformed_json() {
372        assert!(RedisChatMessageStore::deserialize_message("not json").is_err());
373    }
374
375    // endregion
376
377    // region: to_dict()/from_dict() config round trip (no server required)
378
379    #[test]
380    fn to_dict_produces_python_compatible_shape() {
381        let s = store("test_thread_123");
382        let state = s.to_dict();
383        assert_eq!(
384            state,
385            serde_json::json!({
386                "type": "redis_store_state",
387                "session_id": "test_thread_123",
388                "redis_url": "redis://127.0.0.1:6379/0",
389                "key_prefix": "chat_messages",
390                "max_messages": null,
391            })
392        );
393    }
394
395    #[test]
396    fn to_dict_then_from_dict_round_trips() {
397        let s = store("test_thread_123")
398            .with_key_prefix("custom")
399            .with_max_messages(50);
400        let state = s.to_dict();
401
402        let restored = RedisChatMessageStore::from_dict(&state).unwrap();
403        assert_eq!(restored.session_id(), "test_thread_123");
404        assert_eq!(restored.key_prefix(), "custom");
405        assert_eq!(restored.max_messages(), Some(50));
406        assert_eq!(restored.redis_key(), "custom:test_thread_123");
407    }
408
409    #[test]
410    fn from_dict_requires_session_id_and_redis_url() {
411        assert!(RedisChatMessageStore::from_dict(&serde_json::json!({})).is_err());
412        assert!(
413            RedisChatMessageStore::from_dict(&serde_json::json!({"session_id": "t1"})).is_err()
414        );
415    }
416
417    // endregion
418
419    // region: ContextProvider / HistoryProvider surface (pure, no server)
420
421    #[test]
422    fn is_history_provider_reports_true() {
423        assert!(store("t1").is_history_provider());
424    }
425
426    // endregion
427}