Skip to main content

recursive/storage/
mod.rs

1//! Storage abstraction layer for Recursive.
2//!
3//! Defines the traits that decouple the agent kernel from specific
4//! storage backends (local filesystem, Redis, S3, etc.).
5//!
6//! # Design
7//!
8//! Two orthogonal traits cover all persistent data needs:
9//!
10//! - [`StorageBackend`]: long-lived data — transcript and memory entries.
11//!   Implementations include [`local::LocalStorageBackend`] (files) and,
12//!   in cloud deployments, S3 or Postgres.
13//!
14//! - [`SessionStore`]: short-lived hot state used for crash recovery.
15//!   The local implementation [`NoopSessionStore`] is a zero-cost no-op.
16//!   Cloud implementations write to Redis with a TTL.
17//!
18//! The kernel accepts these traits via dependency injection, so the same
19//! code runs in both local and multi-tenant cloud modes.
20
21pub mod local;
22pub use local::LocalStorageBackend;
23
24#[cfg(feature = "cloud-runtime")]
25pub mod redis;
26#[cfg(feature = "cloud-runtime")]
27pub use redis::RedisSessionStore;
28
29#[cfg(feature = "cloud-runtime")]
30pub mod s3;
31#[cfg(feature = "cloud-runtime")]
32pub use s3::S3StorageBackend;
33
34use crate::error::Result;
35use crate::message::Message;
36use async_trait::async_trait;
37
38// ─────────────────────────────────────────────────────────────────────────────
39// StorageBackend
40// ─────────────────────────────────────────────────────────────────────────────
41
42/// Persistent storage for session transcript and memory entries.
43///
44/// # Semantics
45///
46/// - `load_transcript` returns an empty `Vec` (not an error) when the session
47///   does not yet exist.
48/// - `load_memory` returns `None` (not an error) when the key does not exist.
49/// - Implementations must be safe to call concurrently from multiple async
50///   tasks (`Send + Sync + 'static`).
51///
52/// The trait uses `#[async_trait]` so it is `dyn`-compatible and can be
53/// stored as `Arc<dyn StorageBackend>` without generics spreading to callers.
54#[async_trait]
55pub trait StorageBackend: Send + Sync + 'static {
56    /// Load the full transcript for a session.
57    ///
58    /// Returns `Ok(vec![])` if the session has no persisted transcript yet.
59    async fn load_transcript(&self, session_id: &str) -> Result<Vec<Message>>;
60
61    /// Persist the full transcript for a session.
62    ///
63    /// This is a full overwrite — the caller is responsible for appending
64    /// new messages before calling this.
65    async fn save_transcript(&self, session_id: &str, messages: &[Message]) -> Result<()>;
66
67    /// Load a named memory entry (e.g. `"user.md"`, `"project.md"`).
68    ///
69    /// Returns `Ok(None)` if the key has never been written.
70    async fn load_memory(&self, key: &str) -> Result<Option<String>>;
71
72    /// Store a named memory entry.
73    async fn save_memory(&self, key: &str, value: &str) -> Result<()>;
74}
75
76// ─────────────────────────────────────────────────────────────────────────────
77// AgentCheckpointState
78// ─────────────────────────────────────────────────────────────────────────────
79
80/// Opaque snapshot of in-flight agent state for crash recovery / pod migration.
81///
82/// Kept intentionally minimal: only what is needed to resume the Agent Loop
83/// after a pod restart or failover. Full transcript reconstruction uses
84/// `StorageBackend::load_transcript`.
85#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
86pub struct AgentCheckpointState {
87    /// Current step index inside the Agent Loop (0-based).
88    pub step: usize,
89    /// Number of messages in the transcript at the time of this checkpoint.
90    /// Used to verify the transcript is consistent before resuming.
91    pub transcript_len: usize,
92}
93
94// ─────────────────────────────────────────────────────────────────────────────
95// SessionStore
96// ─────────────────────────────────────────────────────────────────────────────
97
98/// Hot-state store for in-flight Agent Loop checkpoints.
99///
100/// The local default implementation is [`NoopSessionStore`] — a zero-cost
101/// no-op that never persists anything. Cloud implementations write to Redis
102/// with a short TTL to enable crash recovery across pod restarts.
103///
104/// # Semantics
105///
106/// - Checkpoint failures are non-fatal by design: the Agent Loop MUST NOT
107///   abort because a `save_state` call failed.  Callers should log the error
108///   and continue.
109/// - `load_state` returns `Ok(None)` when no checkpoint exists (new session).
110/// - `delete_state` is idempotent: deleting a non-existent key is `Ok(())`.
111///
112/// Uses `#[async_trait]` so it is `dyn`-compatible (`Arc<dyn SessionStore>`).
113#[async_trait]
114pub trait SessionStore: Send + Sync + 'static {
115    /// Persist the current loop state for a session.
116    ///
117    /// Called after each tool execution.  Failures should be treated as
118    /// warnings, not errors.
119    async fn save_state(&self, session_id: &str, state: &AgentCheckpointState) -> Result<()>;
120
121    /// Load the most recent checkpoint for a session.
122    ///
123    /// Returns `Ok(None)` if no checkpoint exists (fresh session or already
124    /// cleaned up).
125    async fn load_state(&self, session_id: &str) -> Result<Option<AgentCheckpointState>>;
126
127    /// Remove all checkpoint state for a session.
128    ///
129    /// Should be called after the Agent Loop finishes (success or failure) to
130    /// avoid stale state in Redis/KV.
131    async fn delete_state(&self, session_id: &str) -> Result<()>;
132}
133
134// ─────────────────────────────────────────────────────────────────────────────
135// NoopSessionStore
136// ─────────────────────────────────────────────────────────────────────────────
137
138/// In-memory no-op `SessionStore`.
139///
140/// Used in local single-machine mode where crash recovery is not needed.
141/// All operations are immediate `Ok(())` / `Ok(None)` with zero overhead.
142pub struct NoopSessionStore;
143
144#[async_trait]
145impl SessionStore for NoopSessionStore {
146    async fn save_state(&self, _session_id: &str, _state: &AgentCheckpointState) -> Result<()> {
147        Ok(())
148    }
149
150    async fn load_state(&self, _session_id: &str) -> Result<Option<AgentCheckpointState>> {
151        Ok(None)
152    }
153
154    async fn delete_state(&self, _session_id: &str) -> Result<()> {
155        Ok(())
156    }
157}
158
159// ─────────────────────────────────────────────────────────────────────────────
160// Tests
161// ─────────────────────────────────────────────────────────────────────────────
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[tokio::test]
168    async fn noop_session_store_is_always_empty() {
169        let store = NoopSessionStore;
170        let state = store.load_state("test-session").await.unwrap();
171        assert!(state.is_none());
172    }
173
174    #[tokio::test]
175    async fn noop_session_store_save_and_delete_are_noop() {
176        let store = NoopSessionStore;
177        let checkpoint = AgentCheckpointState {
178            step: 3,
179            transcript_len: 7,
180        };
181
182        store.save_state("session-1", &checkpoint).await.unwrap();
183        store.delete_state("session-1").await.unwrap();
184        // After save + delete, still returns None (noop never stores anything)
185        let loaded = store.load_state("session-1").await.unwrap();
186        assert!(loaded.is_none());
187    }
188
189    #[test]
190    fn agent_checkpoint_state_serializes() {
191        let state = AgentCheckpointState {
192            step: 5,
193            transcript_len: 12,
194        };
195        let json = serde_json::to_string(&state).unwrap();
196        let roundtripped: AgentCheckpointState = serde_json::from_str(&json).unwrap();
197        assert_eq!(state, roundtripped);
198    }
199
200    #[test]
201    fn agent_checkpoint_state_zero_is_valid() {
202        let state = AgentCheckpointState {
203            step: 0,
204            transcript_len: 0,
205        };
206        let json = serde_json::to_string(&state).unwrap();
207        let rt: AgentCheckpointState = serde_json::from_str(&json).unwrap();
208        assert_eq!(rt.step, 0);
209        assert_eq!(rt.transcript_len, 0);
210    }
211}