1use crate::base::{memory_variables_to_messages, BaseMemory};
38use crate::buffer::ConversationBufferMemory;
39use async_trait::async_trait;
40use lc_core::language_models::{BaseChatModel, LLMResult};
41use lc_core::runnables::{LcelError, Runnable, RunnableConfig};
42use lc_schema::Message;
43use std::collections::{HashMap, VecDeque};
44use std::sync::Arc;
45use tokio::sync::Mutex;
46
47pub type SharedMemory = Arc<Mutex<Box<dyn BaseMemory>>>;
49
50const DEFAULT_MAX_SESSIONS: usize = 100;
52
53pub struct RunnableWithMessageHistory<L> {
55 llm: Arc<L>,
56 mode: HistoryMode,
57}
58
59enum HistoryMode {
61 Shared(SharedMemory),
63 Sessions {
65 factory: Arc<dyn Fn(&str) -> SharedMemory + Send + Sync>,
66 cache: Mutex<SessionCache>,
68 max_sessions: usize,
70 default: SharedMemory,
72 },
73}
74
75struct SessionCache {
77 slots: HashMap<String, SharedMemory>,
78 order: VecDeque<String>,
79}
80
81impl<L> RunnableWithMessageHistory<L> {
82 pub fn new(llm: L, memory: impl BaseMemory + 'static) -> Self {
84 Self {
85 llm: Arc::new(llm),
86 mode: HistoryMode::Shared(Arc::new(Mutex::new(Box::new(memory)))),
87 }
88 }
89
90 pub fn with_session_history<F>(llm: L, factory: F) -> Self
108 where
109 F: Fn(&str) -> SharedMemory + Send + Sync + 'static,
110 {
111 Self {
112 llm: Arc::new(llm),
113 mode: HistoryMode::Sessions {
114 factory: Arc::new(factory),
115 cache: Mutex::new(SessionCache {
116 slots: HashMap::new(),
117 order: VecDeque::new(),
118 }),
119 max_sessions: DEFAULT_MAX_SESSIONS,
120 default: Arc::new(Mutex::new(Box::new(ConversationBufferMemory::new()))),
121 },
122 }
123 }
124
125 pub fn with_max_sessions(mut self, max: usize) -> Self {
129 if let HistoryMode::Sessions { max_sessions, .. } = &mut self.mode {
130 *max_sessions = max.max(1);
131 }
132 self
133 }
134
135 pub fn memory(&self) -> SharedMemory {
142 match &self.mode {
143 HistoryMode::Shared(m) => m.clone(),
144 HistoryMode::Sessions { default, .. } => default.clone(),
145 }
146 }
147
148 async fn select_memory(
150 &self,
151 config: &Option<RunnableConfig>,
152 ) -> Result<SharedMemory, LcelError> {
153 match &self.mode {
154 HistoryMode::Shared(m) => Ok(m.clone()),
155 HistoryMode::Sessions {
156 factory,
157 cache,
158 max_sessions,
159 ..
160 } => {
161 let session_id = config
162 .as_ref()
163 .and_then(|c| c.configurable_value("session_id"))
164 .and_then(|v| v.as_str())
165 .ok_or_else(|| {
166 LcelError::Chain(
167 "RunnableWithMessageHistory (session mode) is missing configurable.session_id"
168 .to_string(),
169 )
170 })?;
171 let mut cache = cache.lock().await;
172 if let Some(memory) = cache.slots.get(session_id) {
173 return Ok(memory.clone());
174 }
175 if cache.slots.len() >= *max_sessions {
177 if let Some(oldest) = cache.order.pop_front() {
178 cache.slots.remove(&oldest);
179 }
180 }
181 let memory = factory(session_id);
182 cache.slots.insert(session_id.to_string(), memory.clone());
183 cache.order.push_back(session_id.to_string());
184 Ok(memory)
185 }
186 }
187 }
188}
189
190#[async_trait]
191impl<L> Runnable<String, LLMResult> for RunnableWithMessageHistory<L>
192where
193 L: BaseChatModel + 'static,
194 L::Error: Into<LcelError>,
195{
196 type Error = LcelError;
197
198 async fn invoke(
199 &self,
200 input: String,
201 config: Option<RunnableConfig>,
202 ) -> Result<LLMResult, LcelError> {
203 let memory = self.select_memory(&config).await?;
205
206 let mut memory = memory.lock().await;
212
213 let mut messages = {
215 let vars = memory
216 .load_memory_variables(&HashMap::new())
217 .await
218 .map_err(|e| LcelError::Chain(format!("load memory: {e}")))?;
219 memory_variables_to_messages(&vars)
220 };
221
222 messages.push(Message::human(&input));
224
225 let result = self.llm.chat(messages, config).await.map_err(Into::into)?;
227
228 let inputs = HashMap::from([("input".to_string(), input)]);
231 let outputs = HashMap::from([("output".to_string(), result.content.clone())]);
232 if let Err(e) = memory.save_context(&inputs, &outputs).await {
233 log::warn!("memory save failed (model answer still returned): {e}");
234 }
235
236 Ok(result)
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243 use futures_util::Stream;
244 use lc_core::language_models::{BaseChatModel, BaseLanguageModel, StreamChunk};
245 use lc_core::runnables::RunnableConfig;
246 use lc_schema::MessageType;
247 use serde_json::json;
248 use std::pin::Pin;
249 use std::sync::Mutex as StdMutex;
250
251 struct TestChatModel {
253 seen: Arc<StdMutex<Vec<Vec<Message>>>>,
254 }
255
256 #[async_trait]
257 impl Runnable<Vec<Message>, LLMResult> for TestChatModel {
258 type Error = LcelError;
259
260 async fn invoke(
261 &self,
262 input: Vec<Message>,
263 _config: Option<RunnableConfig>,
264 ) -> Result<LLMResult, LcelError> {
265 self.seen.lock().unwrap().push(input.clone());
266 let last = input.last().map(|m| m.content.clone()).unwrap_or_default();
267 Ok(LLMResult {
268 content: format!("reply to: {last}"),
269 ..Default::default()
270 })
271 }
272 }
273
274 #[async_trait]
275 impl BaseLanguageModel<Vec<Message>, LLMResult> for TestChatModel {
276 fn model_name(&self) -> &str {
277 "test-llm"
278 }
279
280 fn get_num_tokens(&self, text: &str) -> usize {
281 text.len()
282 }
283
284 fn with_temperature(self, _temp: f32) -> Self
285 where
286 Self: Sized,
287 {
288 self
289 }
290
291 fn with_max_tokens(self, _max: usize) -> Self
292 where
293 Self: Sized,
294 {
295 self
296 }
297 }
298
299 #[async_trait]
300 impl BaseChatModel for TestChatModel {
301 async fn chat(
302 &self,
303 messages: Vec<Message>,
304 config: Option<RunnableConfig>,
305 ) -> Result<LLMResult, LcelError> {
306 self.invoke(messages, config).await
307 }
308
309 async fn stream_chat(
310 &self,
311 _messages: Vec<Message>,
312 _config: Option<RunnableConfig>,
313 ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, LcelError>> + Send>>, LcelError>
314 {
315 unimplemented!("stream_chat not needed for tests")
316 }
317 }
318
319 fn session_factory(_session_id: &str) -> SharedMemory {
321 Arc::new(Mutex::new(
322 Box::new(ConversationBufferMemory::new().with_return_messages(true))
323 as Box<dyn BaseMemory>,
324 ))
325 }
326
327 #[tokio::test]
329 async fn reads_memory_writes_back_round_trip() {
330 let seen = Arc::new(StdMutex::new(Vec::new()));
332 let llm = TestChatModel { seen: seen.clone() };
333 let memory = ConversationBufferMemory::new().with_return_messages(true);
335 let pipe = RunnableWithMessageHistory::new(llm, memory);
336
337 let r1 = pipe.invoke("我叫什么名字".to_string(), None).await.unwrap();
339 assert_eq!(r1.content, "reply to: 我叫什么名字");
340
341 let r2 = pipe.invoke("再问一次".to_string(), None).await.unwrap();
343 assert_eq!(r2.content, "reply to: 再问一次");
344
345 let calls = seen.lock().unwrap();
347 assert_eq!(calls.len(), 2, "model should be called twice");
348 assert_eq!(calls[0].len(), 1);
350 assert_eq!(calls[0][0].content, "我叫什么名字");
351 assert_eq!(calls[1].len(), 3);
353 assert_eq!(calls[1][0].content, "我叫什么名字");
354 assert!(matches!(calls[1][0].message_type, MessageType::Human));
355 assert!(matches!(calls[1][1].message_type, MessageType::AI));
356 assert_eq!(calls[1][2].content, "再问一次");
357 }
358
359 #[tokio::test]
361 async fn memory_accumulates_across_invocations() {
362 let seen = Arc::new(StdMutex::new(Vec::new()));
364 let llm = TestChatModel { seen: seen.clone() };
365 let memory = ConversationBufferMemory::new().with_return_messages(true);
366 let pipe = RunnableWithMessageHistory::new(llm, memory);
367
368 for turn in ["你好", "你在吗", "再见"] {
370 pipe.invoke(turn.to_string(), None).await.unwrap();
371 }
372
373 let calls = seen.lock().unwrap();
375 assert_eq!(calls.len(), 3);
376 assert_eq!(calls[2].len(), 5); assert_eq!(calls[2][4].content, "再见");
378 }
379
380 #[tokio::test]
382 async fn session_history_same_session_shares_memory() {
383 let seen = Arc::new(StdMutex::new(Vec::new()));
385 let llm = TestChatModel { seen: seen.clone() };
386 let pipe = RunnableWithMessageHistory::with_session_history(llm, session_factory);
387 let cfg = RunnableConfig::new().with_configurable("session_id", json!("s1"));
388
389 let r1 = pipe
391 .invoke("我叫什么名字".to_string(), Some(cfg.clone()))
392 .await
393 .unwrap();
394 assert_eq!(r1.content, "reply to: 我叫什么名字");
395 let r2 = pipe
396 .invoke("再问一次".to_string(), Some(cfg))
397 .await
398 .unwrap();
399 assert_eq!(r2.content, "reply to: 再问一次");
400
401 let calls = seen.lock().unwrap();
403 assert_eq!(calls.len(), 2);
404 assert_eq!(calls[1].len(), 3);
405 assert_eq!(calls[1][0].content, "我叫什么名字");
406 assert!(matches!(calls[1][1].message_type, MessageType::AI));
407 }
408
409 #[tokio::test]
411 async fn session_history_different_sessions_isolated() {
412 let seen = Arc::new(StdMutex::new(Vec::new()));
414 let llm = TestChatModel { seen: seen.clone() };
415 let pipe = RunnableWithMessageHistory::with_session_history(llm, session_factory);
416 let cfg_s1 = RunnableConfig::new().with_configurable("session_id", json!("s1"));
417 let cfg_s2 = RunnableConfig::new().with_configurable("session_id", json!("s2"));
418
419 pipe.invoke("我是 s1".to_string(), Some(cfg_s1.clone()))
421 .await
422 .unwrap();
423 pipe.invoke("还在 s1".to_string(), Some(cfg_s1))
424 .await
425 .unwrap();
426 pipe.invoke("我是 s2".to_string(), Some(cfg_s2))
427 .await
428 .unwrap();
429
430 let calls = seen.lock().unwrap();
432 assert_eq!(calls.len(), 3);
433 assert_eq!(calls[1].len(), 3, "s1 second turn should see history");
434 assert_eq!(calls[2].len(), 1, "s2 first turn should have no history");
435 }
436
437 #[tokio::test]
439 async fn session_history_missing_session_id_errors() {
440 let seen = Arc::new(StdMutex::new(Vec::new()));
442 let llm = TestChatModel { seen };
443 let pipe = RunnableWithMessageHistory::with_session_history(llm, session_factory);
444
445 let err = pipe.invoke("你好".to_string(), None).await.unwrap_err();
447 assert!(matches!(err, LcelError::Chain(_)));
448
449 let cfg_no_sid = RunnableConfig::new();
450 let err = pipe
451 .invoke("你好".to_string(), Some(cfg_no_sid))
452 .await
453 .unwrap_err();
454 assert!(matches!(err, LcelError::Chain(_)));
455 }
456
457 struct BlockingChatModel {
460 seen: Arc<StdMutex<Vec<Vec<Message>>>>,
461 entered: Arc<tokio::sync::Notify>,
462 release: Arc<tokio::sync::Notify>,
463 blocked: Arc<StdMutex<bool>>,
464 }
465
466 #[async_trait]
467 impl Runnable<Vec<Message>, LLMResult> for BlockingChatModel {
468 type Error = LcelError;
469
470 async fn invoke(
471 &self,
472 input: Vec<Message>,
473 _config: Option<RunnableConfig>,
474 ) -> Result<LLMResult, LcelError> {
475 self.seen.lock().unwrap().push(input.clone());
476 let should_block = {
477 let mut blocked = self.blocked.lock().unwrap();
478 if !*blocked {
479 *blocked = true;
480 true
481 } else {
482 false
483 }
484 };
485 self.entered.notify_one();
486 if should_block {
487 self.release.notified().await;
488 }
489 let last = input.last().map(|m| m.content.clone()).unwrap_or_default();
490 Ok(LLMResult {
491 content: format!("reply to: {last}"),
492 ..Default::default()
493 })
494 }
495 }
496
497 #[async_trait]
498 impl BaseLanguageModel<Vec<Message>, LLMResult> for BlockingChatModel {
499 fn model_name(&self) -> &str {
500 "blocking-test-llm"
501 }
502
503 fn get_num_tokens(&self, text: &str) -> usize {
504 text.len()
505 }
506
507 fn with_temperature(self, _temp: f32) -> Self
508 where
509 Self: Sized,
510 {
511 self
512 }
513
514 fn with_max_tokens(self, _max: usize) -> Self
515 where
516 Self: Sized,
517 {
518 self
519 }
520 }
521
522 #[async_trait]
523 impl BaseChatModel for BlockingChatModel {
524 async fn chat(
525 &self,
526 messages: Vec<Message>,
527 config: Option<RunnableConfig>,
528 ) -> Result<LLMResult, LcelError> {
529 self.invoke(messages, config).await
530 }
531
532 async fn stream_chat(
533 &self,
534 _messages: Vec<Message>,
535 _config: Option<RunnableConfig>,
536 ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, LcelError>> + Send>>, LcelError>
537 {
538 unimplemented!("stream_chat not needed for tests")
539 }
540 }
541
542 #[tokio::test]
544 async fn session_cache_evicts_oldest_when_over_capacity() {
545 let seen = Arc::new(StdMutex::new(Vec::new()));
547 let llm = TestChatModel { seen: seen.clone() };
548 let pipe = RunnableWithMessageHistory::with_session_history(llm, session_factory)
549 .with_max_sessions(2);
550
551 let cfg_s1 = RunnableConfig::new().with_configurable("session_id", json!("s1"));
552 let cfg_s2 = RunnableConfig::new().with_configurable("session_id", json!("s2"));
553 let cfg_s3 = RunnableConfig::new().with_configurable("session_id", json!("s3"));
554
555 pipe.invoke("s1-turn1".to_string(), Some(cfg_s1.clone()))
557 .await
558 .unwrap();
559 pipe.invoke("s2-turn1".to_string(), Some(cfg_s2))
560 .await
561 .unwrap();
562 pipe.invoke("s3-turn1".to_string(), Some(cfg_s3))
563 .await
564 .unwrap();
565 pipe.invoke("s1-turn2".to_string(), Some(cfg_s1))
567 .await
568 .unwrap();
569
570 let calls = seen.lock().unwrap();
572 assert_eq!(calls.len(), 4);
573 assert_eq!(
574 calls[3].len(),
575 1,
576 "M2a: re-entering s1 after eviction should be a fresh session (no history)"
577 );
578 }
579
580 #[tokio::test]
584 async fn concurrent_same_session_invokes_do_not_lose_history() {
585 let seen = Arc::new(StdMutex::new(Vec::new()));
587 let entered = Arc::new(tokio::sync::Notify::new());
588 let release = Arc::new(tokio::sync::Notify::new());
589 let blocked = Arc::new(StdMutex::new(false));
590 let llm = BlockingChatModel {
591 seen: seen.clone(),
592 entered: entered.clone(),
593 release: release.clone(),
594 blocked,
595 };
596 let pipe = Arc::new(RunnableWithMessageHistory::new(
597 llm,
598 ConversationBufferMemory::new().with_return_messages(true),
599 ));
600
601 let p1 = pipe.clone();
603 let h1 = tokio::spawn(async move { p1.invoke("第一轮".to_string(), None).await });
604 entered.notified().await;
605
606 let p2 = pipe.clone();
608 let h2 = tokio::spawn(async move { p2.invoke("第二轮".to_string(), None).await });
609
610 release.notify_one();
612 let r1 = h1.await.unwrap().unwrap();
613 let r2 = h2.await.unwrap().unwrap();
614 assert_eq!(r1.content, "reply to: 第一轮");
615 assert_eq!(r2.content, "reply to: 第二轮");
616
617 let calls = seen.lock().unwrap();
619 assert_eq!(calls.len(), 2);
620 assert_eq!(
621 calls[1].len(),
622 3,
623 "M2b: second turn should see the full first-turn conversation (user+ai+user), not empty history"
624 );
625 assert_eq!(calls[1][0].content, "第一轮");
626 assert_eq!(calls[1][2].content, "第二轮");
627 }
628}