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}