Skip to main content

oxicode_sdk/lifecycle/
snapshot.rs

1//! Agent snapshotting for suspend/resume persistence.
2
3use crate::lifecycle::MetricsSnapshot;
4use oxicode_agent::{AgentConfig, AgentState, ToolRegistry};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::future::Future;
8use std::path::PathBuf;
9use std::pin::Pin;
10
11/// A complete snapshot of an agent at a point in time.
12///
13/// Can be serialized to JSON and stored to disk for suspend/resume.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct AgentSnapshot {
16    /// Unique agent identifier.
17    pub agent_id: String,
18    /// Configuration at time of snapshot.
19    pub config: AgentConfig,
20    /// Conversation state.
21    pub state: AgentState,
22    /// Tool manifest (names/schemas; closures cannot be serialized).
23    pub tool_manifest: ToolManifest,
24    /// Parent agent ID, if this agent was spawned as a child.
25    pub parent_id: Option<String>,
26    /// Wall-clock time when the agent was created (ms since epoch).
27    pub created_at_ms: u64,
28    /// Wall-clock time when the snapshot was taken (ms since epoch).
29    pub snapshot_at_ms: u64,
30    /// Metrics at time of snapshot.
31    pub metrics: MetricsSnapshot,
32    /// Arbitrary extension metadata.
33    #[serde(default)]
34    pub metadata: HashMap<String, serde_json::Value>,
35}
36
37impl AgentSnapshot {
38    /// Create a snapshot from a running agent.
39    pub fn from_agent(
40        agent_id: String,
41        config: &AgentConfig,
42        state: &AgentState,
43        tools: &ToolRegistry,
44        parent_id: Option<String>,
45        metadata: HashMap<String, serde_json::Value>,
46    ) -> Self {
47        let now = now_ms();
48        Self {
49            agent_id,
50            config: config.clone(),
51            state: state.clone(),
52            tool_manifest: ToolManifest::from_registry(tools),
53            parent_id,
54            created_at_ms: now,
55            snapshot_at_ms: now,
56            metrics: MetricsSnapshot::default(),
57            metadata,
58        }
59    }
60
61    /// Serialize to a byte vector.
62    pub fn to_bytes(&self) -> anyhow::Result<Vec<u8>> {
63        Ok(serde_json::to_vec(self)?)
64    }
65
66    /// Deserialize from a byte slice.
67    pub fn from_bytes(bytes: &[u8]) -> anyhow::Result<Self> {
68        Ok(serde_json::from_slice(bytes)?)
69    }
70
71    /// Estimate serialized size.
72    pub fn estimated_size_bytes(&self) -> usize {
73        serde_json::to_vec(self).map(|b| b.len()).unwrap_or(0)
74    }
75}
76
77// ── ToolManifest ────────────────────────────────────────────────────
78
79/// A serializable manifest of tools registered in an agent.
80///
81/// Closures cannot be serialized, so only names, descriptions, and essential
82/// flags are captured. Restoring re-registers them by name.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct ToolManifest {
85    /// Tool entries.
86    pub tools: Vec<ToolManifestEntry>,
87}
88
89impl ToolManifest {
90    /// Build a manifest from an agent's tool registry.
91    pub fn from_registry(registry: &ToolRegistry) -> Self {
92        let tools = registry
93            .definitions()
94            .into_iter()
95            .map(|d| ToolManifestEntry {
96                name: d.name,
97                description: d.description,
98                essential: false,
99            })
100            .collect();
101        Self { tools }
102    }
103
104    /// Determine which tools from this manifest are NOT present in `registry`.
105    pub fn missing_from(&self, registry: &ToolRegistry) -> Vec<&str> {
106        let names: std::collections::HashSet<_> = registry.names().into_iter().collect();
107        self.tools
108            .iter()
109            .filter(|t| !names.contains(&t.name))
110            .map(|t| t.name.as_str())
111            .collect()
112    }
113}
114
115/// A single tool's serializable metadata.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct ToolManifestEntry {
118    /// Tool name.
119    pub name: String,
120    /// Tool description.
121    pub description: String,
122    /// Whether the tool is marked essential.
123    #[serde(default)]
124    pub essential: bool,
125}
126
127// ── SnapshotStore trait ─────────────────────────────────────────────
128
129/// Trait for persisting and retrieving agent snapshots.
130///
131/// Implementations may store to filesystem, database, or network.
132pub trait SnapshotStore: Send + Sync {
133    /// Persist a snapshot.
134    fn save<'a>(
135        &'a self,
136        snapshot: &'a AgentSnapshot,
137    ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + 'a>>;
138
139    /// Retrieve a snapshot by agent ID.
140    fn load<'a>(
141        &'a self,
142        agent_id: &'a str,
143    ) -> Pin<Box<dyn Future<Output = anyhow::Result<Option<AgentSnapshot>>> + Send + 'a>>;
144
145    /// List all agent IDs with stored snapshots.
146    fn list(&self) -> Pin<Box<dyn Future<Output = anyhow::Result<Vec<String>>> + Send + '_>>;
147
148    /// Delete a snapshot by agent ID.
149    fn delete<'a>(
150        &'a self,
151        agent_id: &'a str,
152    ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + 'a>>;
153}
154
155// ── FileSnapshotStore ──────────────────────────────────────────────
156
157/// Snapshot store backed by the local filesystem.
158///
159/// Stores each snapshot as `{base_dir}/{agent_id}.json`.
160#[derive(Debug)]
161pub struct FileSnapshotStore {
162    base_dir: PathBuf,
163}
164
165impl FileSnapshotStore {
166    /// Create a new store rooted at `base_dir`.
167    ///
168    /// The directory is created if it does not exist.
169    pub fn new(base_dir: impl Into<PathBuf>) -> anyhow::Result<Self> {
170        let base_dir = base_dir.into();
171        std::fs::create_dir_all(&base_dir)?;
172        Ok(Self { base_dir })
173    }
174
175    fn snapshot_path(&self, agent_id: &str) -> PathBuf {
176        self.base_dir.join(format!("{agent_id}.json"))
177    }
178}
179
180impl SnapshotStore for FileSnapshotStore {
181    fn save<'a>(
182        &'a self,
183        snapshot: &'a AgentSnapshot,
184    ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + 'a>> {
185        Box::pin(async {
186            let path = self.snapshot_path(&snapshot.agent_id);
187            let bytes = serde_json::to_vec_pretty(snapshot)?;
188            tokio::fs::write(&path, bytes).await?;
189            Ok(())
190        })
191    }
192
193    fn load<'a>(
194        &'a self,
195        agent_id: &'a str,
196    ) -> Pin<Box<dyn Future<Output = anyhow::Result<Option<AgentSnapshot>>> + Send + 'a>> {
197        Box::pin(async {
198            let path = self.snapshot_path(agent_id);
199            if !path.is_file() {
200                return Ok(None);
201            }
202            let bytes = tokio::fs::read(&path).await?;
203            let snapshot: AgentSnapshot = serde_json::from_slice(&bytes)?;
204            Ok(Some(snapshot))
205        })
206    }
207
208    fn list(&self) -> Pin<Box<dyn Future<Output = anyhow::Result<Vec<String>>> + Send + '_>> {
209        Box::pin(async {
210            let mut entries = Vec::new();
211            let mut dir = tokio::fs::read_dir(&self.base_dir).await?;
212            while let Some(entry) = dir.next_entry().await? {
213                if entry.path().extension().is_some_and(|e| e == "json")
214                    && let Some(name) = entry.path().file_stem()
215                {
216                    entries.push(name.to_string_lossy().to_string());
217                }
218            }
219            Ok(entries)
220        })
221    }
222
223    fn delete<'a>(
224        &'a self,
225        agent_id: &'a str,
226    ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + 'a>> {
227        Box::pin(async {
228            let path = self.snapshot_path(agent_id);
229            if path.is_file() {
230                tokio::fs::remove_file(&path).await?;
231            }
232            Ok(())
233        })
234    }
235}
236
237// ── Helpers ───────────────────────────────────────────────────────────
238
239fn now_ms() -> u64 {
240    std::time::SystemTime::now()
241        .duration_since(std::time::UNIX_EPOCH)
242        .map(|d| d.as_millis() as u64)
243        .unwrap_or(0)
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use std::sync::Arc;
250    use tempfile::TempDir;
251
252    fn test_snapshot() -> AgentSnapshot {
253        AgentSnapshot {
254            agent_id: "test-agent".into(),
255            config: AgentConfig::default(),
256            state: AgentState::default(),
257            tool_manifest: ToolManifest { tools: vec![] },
258            parent_id: None,
259            created_at_ms: 1_000_000_000_000,
260            snapshot_at_ms: 1_000_000_000_100,
261            metrics: MetricsSnapshot {
262                total_runs: 5,
263                successful_runs: 4,
264                failed_runs: 1,
265                total_input_tokens: 35_000,
266                total_output_tokens: 15_000,
267                total_tokens: 50_000,
268                tool_calls: 20,
269                total_duration_ms: 30_000,
270            },
271            metadata: HashMap::new(),
272        }
273    }
274
275    #[test]
276    fn snapshot_roundtrip_json() {
277        let snapshot = test_snapshot();
278        let json = serde_json::to_string(&snapshot).unwrap();
279        let back: AgentSnapshot = serde_json::from_str(&json).unwrap();
280        assert_eq!(back.agent_id, "test-agent");
281        assert_eq!(back.metrics.total_runs, 5);
282    }
283
284    #[test]
285    fn snapshot_roundtrip_bytes() {
286        let snapshot = test_snapshot();
287        let bytes = snapshot.to_bytes().unwrap();
288        let back = AgentSnapshot::from_bytes(&bytes).unwrap();
289        assert_eq!(back.agent_id, "test-agent");
290    }
291
292    #[test]
293    fn snapshot_estimated_size() {
294        let snapshot = test_snapshot();
295        assert!(snapshot.estimated_size_bytes() > 0);
296    }
297
298    #[test]
299    fn tool_manifest_from_empty_registry() {
300        let registry = Arc::new(ToolRegistry::new());
301        let manifest = ToolManifest::from_registry(&registry);
302        assert!(manifest.tools.is_empty());
303        assert!(manifest.missing_from(&registry).is_empty());
304    }
305
306    #[tokio::test]
307    async fn file_snapshot_store_save_load() {
308        let tmp = TempDir::new().unwrap();
309        let store = FileSnapshotStore::new(tmp.path()).unwrap();
310
311        let snapshot = test_snapshot();
312        store.save(&snapshot).await.unwrap();
313
314        let loaded = store.load("test-agent").await.unwrap().unwrap();
315        assert_eq!(loaded.agent_id, "test-agent");
316        assert_eq!(loaded.metrics.total_runs, 5);
317    }
318
319    #[tokio::test]
320    async fn file_snapshot_store_load_missing() {
321        let tmp = TempDir::new().unwrap();
322        let store = FileSnapshotStore::new(tmp.path()).unwrap();
323        let result = store.load("does-not-exist").await.unwrap();
324        assert!(result.is_none());
325    }
326
327    #[tokio::test]
328    async fn file_snapshot_store_delete() {
329        let tmp = TempDir::new().unwrap();
330        let store = FileSnapshotStore::new(tmp.path()).unwrap();
331
332        let snapshot = test_snapshot();
333        store.save(&snapshot).await.unwrap();
334        store.delete("test-agent").await.unwrap();
335
336        let result = store.load("test-agent").await.unwrap();
337        assert!(result.is_none());
338    }
339
340    #[tokio::test]
341    async fn file_snapshot_store_list() {
342        let tmp = TempDir::new().unwrap();
343        let store = FileSnapshotStore::new(tmp.path()).unwrap();
344
345        let mut s1 = test_snapshot();
346        s1.agent_id = "alpha".into();
347        store.save(&s1).await.unwrap();
348
349        let mut s2 = test_snapshot();
350        s2.agent_id = "beta".into();
351        store.save(&s2).await.unwrap();
352
353        let ids = store.list().await.unwrap();
354        assert_eq!(ids.len(), 2);
355        assert!(ids.contains(&"alpha".into()));
356        assert!(ids.contains(&"beta".into()));
357    }
358}