use crate::lifecycle::MetricsSnapshot;
use async_trait::async_trait;
use oxi_agent::{AgentConfig, AgentState, ToolRegistry};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentSnapshot {
pub agent_id: String,
pub config: AgentConfig,
pub state: AgentState,
pub tool_manifest: ToolManifest,
pub parent_id: Option<String>,
pub created_at_ms: u64,
pub snapshot_at_ms: u64,
pub metrics: MetricsSnapshot,
#[serde(default)]
pub metadata: HashMap<String, serde_json::Value>,
}
impl AgentSnapshot {
pub fn from_agent(
agent_id: String,
config: &AgentConfig,
state: &AgentState,
tools: &ToolRegistry,
parent_id: Option<String>,
metadata: HashMap<String, serde_json::Value>,
) -> Self {
let now = now_ms();
Self {
agent_id,
config: config.clone(),
state: state.clone(),
tool_manifest: ToolManifest::from_registry(tools),
parent_id,
created_at_ms: now,
snapshot_at_ms: now,
metrics: MetricsSnapshot::default(),
metadata,
}
}
pub fn to_bytes(&self) -> anyhow::Result<Vec<u8>> {
Ok(serde_json::to_vec(self)?)
}
pub fn from_bytes(bytes: &[u8]) -> anyhow::Result<Self> {
Ok(serde_json::from_slice(bytes)?)
}
pub fn estimated_size_bytes(&self) -> usize {
serde_json::to_vec(self).map(|b| b.len()).unwrap_or(0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolManifest {
pub tools: Vec<ToolManifestEntry>,
}
impl ToolManifest {
pub fn from_registry(registry: &ToolRegistry) -> Self {
let tools = registry
.definitions()
.into_iter()
.map(|d| ToolManifestEntry {
name: d.name,
description: d.description,
essential: false,
})
.collect();
Self { tools }
}
pub fn missing_from(&self, registry: &ToolRegistry) -> Vec<&str> {
let names: std::collections::HashSet<_> = registry.names().into_iter().collect();
self.tools
.iter()
.filter(|t| !names.contains(&t.name))
.map(|t| t.name.as_str())
.collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolManifestEntry {
pub name: String,
pub description: String,
#[serde(default)]
pub essential: bool,
}
#[async_trait]
pub trait SnapshotStore: Send + Sync {
async fn save(&self, snapshot: &AgentSnapshot) -> anyhow::Result<()>;
async fn load(&self, agent_id: &str) -> anyhow::Result<Option<AgentSnapshot>>;
async fn list(&self) -> anyhow::Result<Vec<String>>;
async fn delete(&self, agent_id: &str) -> anyhow::Result<()>;
}
#[derive(Debug)]
pub struct FileSnapshotStore {
base_dir: PathBuf,
}
impl FileSnapshotStore {
pub fn new(base_dir: impl Into<PathBuf>) -> anyhow::Result<Self> {
let base_dir = base_dir.into();
std::fs::create_dir_all(&base_dir)?;
Ok(Self { base_dir })
}
fn snapshot_path(&self, agent_id: &str) -> PathBuf {
self.base_dir.join(format!("{agent_id}.json"))
}
}
#[async_trait]
impl SnapshotStore for FileSnapshotStore {
async fn save(&self, snapshot: &AgentSnapshot) -> anyhow::Result<()> {
let path = self.snapshot_path(&snapshot.agent_id);
let bytes = serde_json::to_vec_pretty(snapshot)?;
tokio::fs::write(&path, bytes).await?;
Ok(())
}
async fn load(&self, agent_id: &str) -> anyhow::Result<Option<AgentSnapshot>> {
let path = self.snapshot_path(agent_id);
if !path.is_file() {
return Ok(None);
}
let bytes = tokio::fs::read(&path).await?;
let snapshot: AgentSnapshot = serde_json::from_slice(&bytes)?;
Ok(Some(snapshot))
}
async fn list(&self) -> anyhow::Result<Vec<String>> {
let mut entries = Vec::new();
let mut dir = tokio::fs::read_dir(&self.base_dir).await?;
while let Some(entry) = dir.next_entry().await? {
if entry.path().extension().is_some_and(|e| e == "json") {
if let Some(name) = entry.path().file_stem() {
entries.push(name.to_string_lossy().to_string());
}
}
}
Ok(entries)
}
async fn delete(&self, agent_id: &str) -> anyhow::Result<()> {
let path = self.snapshot_path(agent_id);
if path.is_file() {
tokio::fs::remove_file(&path).await?;
}
Ok(())
}
}
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use tempfile::TempDir;
fn test_snapshot() -> AgentSnapshot {
AgentSnapshot {
agent_id: "test-agent".into(),
config: AgentConfig::default(),
state: AgentState::default(),
tool_manifest: ToolManifest { tools: vec![] },
parent_id: None,
created_at_ms: 1_000_000_000_000,
snapshot_at_ms: 1_000_000_000_100,
metrics: MetricsSnapshot {
total_runs: 5,
successful_runs: 4,
failed_runs: 1,
total_input_tokens: 35_000,
total_output_tokens: 15_000,
total_tokens: 50_000,
tool_calls: 20,
total_duration_ms: 30_000,
},
metadata: HashMap::new(),
}
}
#[test]
fn snapshot_roundtrip_json() {
let snapshot = test_snapshot();
let json = serde_json::to_string(&snapshot).unwrap();
let back: AgentSnapshot = serde_json::from_str(&json).unwrap();
assert_eq!(back.agent_id, "test-agent");
assert_eq!(back.metrics.total_runs, 5);
}
#[test]
fn snapshot_roundtrip_bytes() {
let snapshot = test_snapshot();
let bytes = snapshot.to_bytes().unwrap();
let back = AgentSnapshot::from_bytes(&bytes).unwrap();
assert_eq!(back.agent_id, "test-agent");
}
#[test]
fn snapshot_estimated_size() {
let snapshot = test_snapshot();
assert!(snapshot.estimated_size_bytes() > 0);
}
#[test]
fn tool_manifest_from_empty_registry() {
let registry = Arc::new(ToolRegistry::new());
let manifest = ToolManifest::from_registry(®istry);
assert!(manifest.tools.is_empty());
assert!(manifest.missing_from(®istry).is_empty());
}
#[tokio::test]
async fn file_snapshot_store_save_load() {
let tmp = TempDir::new().unwrap();
let store = FileSnapshotStore::new(tmp.path()).unwrap();
let snapshot = test_snapshot();
store.save(&snapshot).await.unwrap();
let loaded = store.load("test-agent").await.unwrap().unwrap();
assert_eq!(loaded.agent_id, "test-agent");
assert_eq!(loaded.metrics.total_runs, 5);
}
#[tokio::test]
async fn file_snapshot_store_load_missing() {
let tmp = TempDir::new().unwrap();
let store = FileSnapshotStore::new(tmp.path()).unwrap();
let result = store.load("does-not-exist").await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn file_snapshot_store_delete() {
let tmp = TempDir::new().unwrap();
let store = FileSnapshotStore::new(tmp.path()).unwrap();
let snapshot = test_snapshot();
store.save(&snapshot).await.unwrap();
store.delete("test-agent").await.unwrap();
let result = store.load("test-agent").await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn file_snapshot_store_list() {
let tmp = TempDir::new().unwrap();
let store = FileSnapshotStore::new(tmp.path()).unwrap();
let mut s1 = test_snapshot();
s1.agent_id = "alpha".into();
store.save(&s1).await.unwrap();
let mut s2 = test_snapshot();
s2.agent_id = "beta".into();
store.save(&s2).await.unwrap();
let ids = store.list().await.unwrap();
assert_eq!(ids.len(), 2);
assert!(ids.contains(&"alpha".into()));
assert!(ids.contains(&"beta".into()));
}
}