agent_framework_redis/
chat_message_store.rs1use 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
34pub const DEFAULT_KEY_PREFIX: &str = "chat_messages";
36
37pub 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 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 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 pub fn with_max_messages(mut self, max_messages: usize) -> Self {
92 self.max_messages = Some(max_messages);
93 self
94 }
95
96 pub fn session_id(&self) -> &str {
98 &self.session_id
99 }
100
101 pub fn key_prefix(&self) -> &str {
103 &self.key_prefix
104 }
105
106 pub fn max_messages(&self) -> Option<usize> {
108 self.max_messages
109 }
110
111 pub fn redis_key(&self) -> String {
113 format!("{}:{}", self.key_prefix, self.session_id)
114 }
115
116 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 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 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 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 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 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 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 RedisChatMessageStore::new("redis://127.0.0.1:6379/0", Some(session_id.to_string()))
274 .expect("valid redis url")
275 }
276
277 #[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 assert!(s.session_id().len() > 10);
297 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 #[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 #[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 #[test]
422 fn is_history_provider_reports_true() {
423 assert!(store("t1").is_history_provider());
424 }
425
426 }