Skip to main content

agent_framework_core/
history.rs

1//! Conversation-history context providers.
2//!
3//! Upstream moved conversation history out of the thread/session entirely:
4//! it is now just another [`ContextProvider`] — a [`HistoryProvider`] —
5//! that prepends its stored messages ahead of a run (`before_run`) and
6//! records the run's request + response messages after a successful run
7//! (`after_run`). [`InMemoryHistoryProvider`] is the in-process default;
8//! [`FileHistoryProvider`] persists to a JSON file on disk.
9//!
10//! [`Agent`](crate::agent::Agent) and
11//! [`WorkflowAgent`](crate::workflow::WorkflowAgent) auto-attach a fresh
12//! [`InMemoryHistoryProvider`] (via [`ensure_history_provider`]) to any
13//! non-service-managed [`AgentSession`] that doesn't already carry a history
14//! provider, so local multi-turn conversations keep accumulating history the
15//! way the old `AgentThread` message store used to.
16
17use std::path::PathBuf;
18use std::sync::{Arc, Mutex};
19
20use async_trait::async_trait;
21use serde_json::Value;
22
23use crate::error::{Error, Result};
24use crate::memory::{ContextProvider, SessionContext};
25use crate::session::AgentSession;
26use crate::types::Message;
27
28/// A [`ContextProvider`] that also manages conversation history.
29///
30/// This is a marker trait (over and above [`ContextProvider::is_history_provider`],
31/// which drives runtime detection via trait objects): implementing it
32/// documents that a type's `before_run`/`after_run` are the ones responsible
33/// for a session's conversation history, distinguishing it from a generic
34/// memory/RAG provider.
35pub trait HistoryProvider: ContextProvider {}
36
37/// Attach a fresh [`InMemoryHistoryProvider`] as the **first** context
38/// provider on `session` when it is not service-managed and does not already
39/// carry a history provider. A no-op for service-managed sessions (the
40/// service owns history server-side) and for sessions that already have one
41/// attached (detected via [`ContextProvider::is_history_provider`]).
42pub fn ensure_history_provider(session: &mut AgentSession) {
43    if session.service_session_id().is_none()
44        && !session
45            .context_providers
46            .iter()
47            .any(|p| p.is_history_provider())
48    {
49        session
50            .context_providers
51            .insert(0, Arc::new(InMemoryHistoryProvider::new()));
52    }
53}
54
55/// In-memory [`HistoryProvider`]: keeps history in an `Arc<Mutex<Vec<Message>>>`,
56/// shared across clones.
57#[derive(Default, Clone)]
58pub struct InMemoryHistoryProvider {
59    messages: Arc<Mutex<Vec<Message>>>,
60}
61
62impl InMemoryHistoryProvider {
63    /// An empty history provider.
64    pub fn new() -> Self {
65        Self::default()
66    }
67
68    /// A history provider seeded with `messages`.
69    pub fn with_messages(messages: Vec<Message>) -> Self {
70        Self {
71            messages: Arc::new(Mutex::new(messages)),
72        }
73    }
74
75    /// The stored messages, in chronological order.
76    pub fn list_messages(&self) -> Vec<Message> {
77        self.messages.lock().unwrap().clone()
78    }
79
80    /// Serialize the stored history to `{"messages": [...]}`.
81    pub fn to_dict(&self) -> Value {
82        serde_json::json!({ "messages": self.list_messages() })
83    }
84
85    /// Reconstruct a provider from state produced by [`InMemoryHistoryProvider::to_dict`].
86    pub fn from_dict(state: &Value) -> Result<Self> {
87        let messages = match state.get("messages") {
88            Some(v) if !v.is_null() => serde_json::from_value(v.clone()).map_err(|e| {
89                Error::Serialization(format!("failed to restore history provider: {e}"))
90            })?,
91            _ => Vec::new(),
92        };
93        Ok(Self::with_messages(messages))
94    }
95}
96
97#[async_trait]
98impl ContextProvider for InMemoryHistoryProvider {
99    async fn before_run(&self, ctx: &mut SessionContext) -> Result<()> {
100        let stored = self.messages.lock().unwrap().clone();
101        let existing = std::mem::take(&mut ctx.messages);
102        ctx.messages = stored.into_iter().chain(existing).collect();
103        Ok(())
104    }
105
106    async fn after_run(
107        &self,
108        request_messages: &[Message],
109        response_messages: &[Message],
110        error: Option<&Error>,
111    ) -> Result<()> {
112        if error.is_none() {
113            let mut guard = self.messages.lock().unwrap();
114            guard.extend(request_messages.iter().cloned());
115            guard.extend(response_messages.iter().cloned());
116        }
117        Ok(())
118    }
119
120    fn is_history_provider(&self) -> bool {
121        true
122    }
123}
124
125impl HistoryProvider for InMemoryHistoryProvider {}
126
127/// A [`HistoryProvider`] that persists to a JSON file on disk, loading any
128/// existing history from `path` on construction and rewriting the whole file
129/// after every successful run.
130///
131/// Persistence is **atomic and concurrency-safe**: `after_run` serializes the
132/// whole append→snapshot→write sequence behind an async `write_lock` (shared
133/// across clones), writes to a temporary sibling file, and atomically renames
134/// it into place. The in-memory history is only updated *after* the on-disk
135/// write succeeds, so a failed write never diverges memory from disk, and two
136/// concurrent runs sharing cloned providers can't lose each other's messages
137/// via a snapshot/overwrite race.
138#[derive(Clone)]
139pub struct FileHistoryProvider {
140    path: PathBuf,
141    messages: Arc<Mutex<Vec<Message>>>,
142    /// Serializes the append+snapshot+persist critical section across all
143    /// clones so concurrent `after_run` calls can't interleave into a lost
144    /// update. Held only in `after_run`; reads (`before_run`/`list_messages`)
145    /// take the fast in-memory `messages` lock and never block on this.
146    write_lock: Arc<tokio::sync::Mutex<()>>,
147}
148
149impl FileHistoryProvider {
150    /// Open (or create) a file-backed history provider at `path`. A missing
151    /// or empty file starts with no history; an existing file is parsed
152    /// eagerly, so a malformed file fails the constructor rather than
153    /// silently discarding history.
154    pub fn new(path: impl Into<PathBuf>) -> Result<Self> {
155        let path = path.into();
156        let messages = if path.exists() {
157            let data = std::fs::read_to_string(&path)
158                .map_err(|e| Error::other(format!("failed to read history file {path:?}: {e}")))?;
159            if data.trim().is_empty() {
160                Vec::new()
161            } else {
162                let value: Value = serde_json::from_str(&data).map_err(|e| {
163                    Error::Serialization(format!("failed to parse history file {path:?}: {e}"))
164                })?;
165                match value.get("messages") {
166                    Some(v) if !v.is_null() => serde_json::from_value(v.clone()).map_err(|e| {
167                        Error::Serialization(format!("failed to parse history file {path:?}: {e}"))
168                    })?,
169                    _ => Vec::new(),
170                }
171            }
172        } else {
173            Vec::new()
174        };
175        Ok(Self {
176            path,
177            messages: Arc::new(Mutex::new(messages)),
178            write_lock: Arc::new(tokio::sync::Mutex::new(())),
179        })
180    }
181
182    /// The path this provider persists to.
183    pub fn path(&self) -> &std::path::Path {
184        &self.path
185    }
186
187    /// The stored messages, in chronological order.
188    pub fn list_messages(&self) -> Vec<Message> {
189        self.messages.lock().unwrap().clone()
190    }
191
192    /// Serialize the stored history to `{"messages": [...]}`.
193    pub fn to_dict(&self) -> Value {
194        serde_json::json!({ "messages": self.list_messages() })
195    }
196
197    /// Write `messages` as `{"messages": [...]}` via a temp-file-plus-rename so
198    /// the destination is replaced **atomically**: serialize, write to a
199    /// uniquely named temporary sibling file, then rename it over the
200    /// destination. The rename is atomic on a POSIX filesystem, so a reader
201    /// (or a crash) sees either the old file or the fully-written new one,
202    /// never a truncated file.
203    ///
204    /// This guarantees atomic *replacement*, not fsync-level crash durability:
205    /// like the sibling checkpoint writer, it does not `sync_all` the file or
206    /// its directory, so a power loss immediately after the rename may still
207    /// lose the last write. That is an intentional trade-off for these small,
208    /// frequently-rewritten history files.
209    async fn persist(&self, messages: &[Message]) -> Result<()> {
210        let dict = serde_json::json!({ "messages": messages });
211        let json = serde_json::to_string_pretty(&dict)
212            .map_err(|e| Error::Serialization(format!("failed to serialize history: {e}")))?;
213        // Temp file in the same directory so `rename` stays on one filesystem.
214        // A uuid suffix keeps two providers on the same path from clobbering
215        // each other's temp file.
216        let file_name = self
217            .path
218            .file_name()
219            .and_then(|f| f.to_str())
220            .unwrap_or("history.json");
221        let tmp = self
222            .path
223            .with_file_name(format!("{file_name}.tmp.{}", uuid::Uuid::new_v4()));
224        if let Err(e) = tokio::fs::write(&tmp, &json).await {
225            // Don't leave the partial temp file behind on a failed write.
226            let _ = tokio::fs::remove_file(&tmp).await;
227            return Err(Error::other(format!(
228                "failed to write history temp file {tmp:?}: {e}"
229            )));
230        }
231        tokio::fs::rename(&tmp, &self.path).await.map_err(|e| {
232            // Best-effort cleanup of the temp file on a failed rename.
233            let tmp = tmp.clone();
234            tokio::spawn(async move {
235                let _ = tokio::fs::remove_file(&tmp).await;
236            });
237            Error::other(format!(
238                "failed to finalize history file {:?}: {e}",
239                self.path
240            ))
241        })
242    }
243}
244
245#[async_trait]
246impl ContextProvider for FileHistoryProvider {
247    async fn before_run(&self, ctx: &mut SessionContext) -> Result<()> {
248        let stored = self.messages.lock().unwrap().clone();
249        let existing = std::mem::take(&mut ctx.messages);
250        ctx.messages = stored.into_iter().chain(existing).collect();
251        Ok(())
252    }
253
254    async fn after_run(
255        &self,
256        request_messages: &[Message],
257        response_messages: &[Message],
258        error: Option<&Error>,
259    ) -> Result<()> {
260        if error.is_some() {
261            return Ok(());
262        }
263        // Serialize the whole append→snapshot→persist sequence so two
264        // concurrent runs (sharing cloned providers) can't interleave a
265        // snapshot and an overwrite into a lost update.
266        let _write = self.write_lock.lock().await;
267
268        // Compute the next full history WITHOUT committing it to shared memory
269        // yet: disk is the source of truth. We persist first and only update
270        // the in-memory copy on success, so a failed write leaves memory and
271        // disk consistent (the run's `after_run` returns the error and the
272        // caller can retry) rather than diverging.
273        let snapshot = {
274            let guard = self.messages.lock().unwrap();
275            let mut next = guard.clone();
276            next.extend(request_messages.iter().cloned());
277            next.extend(response_messages.iter().cloned());
278            next
279        };
280        self.persist(&snapshot).await?;
281        *self.messages.lock().unwrap() = snapshot;
282        Ok(())
283    }
284
285    fn is_history_provider(&self) -> bool {
286        true
287    }
288}
289
290impl HistoryProvider for FileHistoryProvider {}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use crate::types::Message;
296
297    #[tokio::test]
298    async fn before_run_prepends_stored_messages_ahead_of_existing_context_messages() {
299        let provider = InMemoryHistoryProvider::with_messages(vec![
300            Message::user("q1"),
301            Message::assistant("a1"),
302        ]);
303        let mut ctx = SessionContext::new(vec![Message::user("q2")]);
304        ctx.messages
305            .push(Message::system("injected by another provider"));
306        provider.before_run(&mut ctx).await.unwrap();
307        let texts: Vec<String> = ctx.messages.iter().map(|m| m.text()).collect();
308        assert_eq!(
309            texts,
310            vec![
311                "q1".to_string(),
312                "a1".to_string(),
313                "injected by another provider".to_string(),
314            ]
315        );
316    }
317
318    #[tokio::test]
319    async fn after_run_appends_only_on_success() {
320        let provider = InMemoryHistoryProvider::new();
321        provider
322            .after_run(&[Message::user("hi")], &[Message::assistant("hello")], None)
323            .await
324            .unwrap();
325        assert_eq!(provider.list_messages().len(), 2);
326
327        // A failed run must not record anything.
328        provider
329            .after_run(
330                &[Message::user("again")],
331                &[],
332                Some(&Error::service("boom")),
333            )
334            .await
335            .unwrap();
336        assert_eq!(provider.list_messages().len(), 2);
337    }
338
339    #[test]
340    fn to_dict_from_dict_round_trips_messages() {
341        let provider = InMemoryHistoryProvider::with_messages(vec![
342            Message::user("q1"),
343            Message::assistant("a1"),
344        ]);
345        let state = provider.to_dict();
346        let restored = InMemoryHistoryProvider::from_dict(&state).unwrap();
347        let msgs = restored.list_messages();
348        assert_eq!(msgs.len(), 2);
349        assert_eq!(msgs[0].text(), "q1");
350        assert_eq!(msgs[1].text(), "a1");
351    }
352
353    #[test]
354    fn from_dict_tolerates_a_missing_messages_key() {
355        let restored = InMemoryHistoryProvider::from_dict(&serde_json::json!({})).unwrap();
356        assert!(restored.list_messages().is_empty());
357    }
358
359    #[test]
360    fn ensure_history_provider_attaches_once_and_skips_service_managed() {
361        let mut local = AgentSession::new();
362        ensure_history_provider(&mut local);
363        assert_eq!(local.context_providers.len(), 1);
364        assert!(local.context_providers[0].is_history_provider());
365        // A second call must not attach a duplicate.
366        ensure_history_provider(&mut local);
367        assert_eq!(local.context_providers.len(), 1);
368
369        let mut service = AgentSession::service("svc-1");
370        ensure_history_provider(&mut service);
371        assert!(service.context_providers.is_empty());
372    }
373
374    #[tokio::test]
375    async fn file_history_provider_persists_and_reloads() {
376        let dir = std::env::temp_dir().join(format!("afr-history-test-{}", uuid::Uuid::new_v4()));
377        std::fs::create_dir_all(&dir).unwrap();
378        let path = dir.join("history.json");
379
380        let provider = FileHistoryProvider::new(&path).unwrap();
381        assert!(provider.list_messages().is_empty());
382        provider
383            .after_run(&[Message::user("hi")], &[Message::assistant("hello")], None)
384            .await
385            .unwrap();
386        assert_eq!(provider.list_messages().len(), 2);
387
388        // A fresh provider opened on the same path picks up the persisted
389        // history.
390        let reloaded = FileHistoryProvider::new(&path).unwrap();
391        let msgs = reloaded.list_messages();
392        assert_eq!(msgs.len(), 2);
393        assert_eq!(msgs[0].text(), "hi");
394        assert_eq!(msgs[1].text(), "hello");
395
396        std::fs::remove_dir_all(&dir).ok();
397    }
398
399    #[tokio::test]
400    async fn file_history_provider_concurrent_runs_do_not_lose_messages() {
401        // Regression for the snapshot/overwrite race: many concurrent
402        // `after_run` calls on cloned providers must all be durably recorded,
403        // and the on-disk file must always be valid JSON (atomic rename).
404        let dir = std::env::temp_dir().join(format!("afr-history-conc-{}", uuid::Uuid::new_v4()));
405        std::fs::create_dir_all(&dir).unwrap();
406        let path = dir.join("history.json");
407
408        let provider = FileHistoryProvider::new(&path).unwrap();
409        const N: usize = 50;
410        let mut handles = Vec::new();
411        for i in 0..N {
412            let p = provider.clone();
413            handles.push(tokio::spawn(async move {
414                p.after_run(
415                    &[Message::user(format!("q{i}"))],
416                    &[Message::assistant(format!("a{i}"))],
417                    None,
418                )
419                .await
420                .unwrap();
421            }));
422        }
423        for h in handles {
424            h.await.unwrap();
425        }
426
427        // Every run contributed a request + response message; none lost.
428        assert_eq!(provider.list_messages().len(), N * 2);
429
430        // The on-disk file is valid and holds the full history (atomic rename
431        // means it is never a torn/partial write).
432        let reloaded = FileHistoryProvider::new(&path).unwrap();
433        assert_eq!(reloaded.list_messages().len(), N * 2);
434
435        // No temp files left behind.
436        let leftover: Vec<_> = std::fs::read_dir(&dir)
437            .unwrap()
438            .filter_map(|e| e.ok())
439            .filter(|e| e.file_name().to_string_lossy().contains(".tmp."))
440            .collect();
441        assert!(leftover.is_empty(), "temp files leaked: {leftover:?}");
442
443        std::fs::remove_dir_all(&dir).ok();
444    }
445}