echo_agent 0.1.4

Production-grade AI Agent framework for Rust — ReAct engine, multi-agent, memory, streaming, MCP, IM channels, workflows
Documentation
//! Plan persistence layer — stores plans to SQLite via SqliteStore

use crate::error::Result;
use crate::memory::store::Store;
use echo_core::agent::{Plan, PlanStore, PlanSummary};
use futures::future::BoxFuture;
use serde_json::json;
use std::sync::Arc;

/// SQLite-backed plan store using the existing SqliteStore
pub struct SqlitePlanStore {
    store: Arc<dyn Store>,
}

const PLAN_NAMESPACE: &[&str] = &["plans"];

impl SqlitePlanStore {
    /// Create a SQLite-backed plan store
    ///
    /// # Parameters
    /// * `store` - Underlying storage implementation
    pub fn new(store: Arc<dyn Store>) -> Self {
        Self { store }
    }

    fn plan_to_value(plan: &Plan) -> serde_json::Value {
        json!({
            "id": plan.id,
            "version": plan.version,
            "slug": plan.slug,
            "steps": plan.steps,
            "goal": plan.goal,
            "parent_plan_id": plan.parent_plan_id,
            "metadata": plan.metadata,
            "created_at": plan.created_at,
            "updated_at": plan.updated_at,
            "content": plan.goal.clone().unwrap_or_default(),
        })
    }
}

impl PlanStore for SqlitePlanStore {
    fn save_plan<'a>(&'a self, plan: &'a Plan) -> BoxFuture<'a, Result<()>> {
        Box::pin(async move {
            let key = plan.id.as_deref().unwrap_or("unknown");
            let value = Self::plan_to_value(plan);
            self.store
                .put(PLAN_NAMESPACE, key, value)
                .await
                .map_err(|e| crate::error::ReactError::Other(format!("save_plan: {}", e)))
        })
    }

    fn load_plan<'a>(&'a self, plan_id: &'a str) -> BoxFuture<'a, Result<Option<Plan>>> {
        Box::pin(async move {
            let item = self
                .store
                .get(PLAN_NAMESPACE, plan_id)
                .await
                .map_err(|e| crate::error::ReactError::Other(format!("load_plan: {}", e)))?;

            match item {
                Some(item) => {
                    let plan = serde_json::from_value(item.value).map_err(|e| {
                        crate::error::ReactError::Other(format!("load_plan parse: {}", e))
                    })?;
                    Ok(Some(plan))
                }
                None => Ok(None),
            }
        })
    }

    fn load_plan_by_slug<'a>(&'a self, slug: &'a str) -> BoxFuture<'a, Result<Option<Plan>>> {
        Box::pin(async move {
            let results = self
                .store
                .search(PLAN_NAMESPACE, slug, 20)
                .await
                .map_err(|e| {
                    crate::error::ReactError::Other(format!("load_plan_by_slug: {}", e))
                })?;

            for item in results {
                if let Ok(plan) = serde_json::from_value::<Plan>(item.value)
                    && plan.slug.as_deref() == Some(slug)
                {
                    return Ok(Some(plan));
                }
            }

            Ok(None)
        })
    }

    fn list_plans<'a>(&'a self, limit: usize) -> BoxFuture<'a, Result<Vec<PlanSummary>>> {
        Box::pin(async move {
            let results = self
                .store
                .search(PLAN_NAMESPACE, "", limit)
                .await
                .map_err(|e| crate::error::ReactError::Other(format!("list_plans: {}", e)))?;

            let mut summaries = Vec::new();
            for item in results {
                if let Ok(plan) = serde_json::from_value::<Plan>(item.value) {
                    summaries.push(PlanSummary {
                        id: plan.id.clone().unwrap_or_default(),
                        slug: plan.slug.clone(),
                        goal: plan.goal.clone(),
                        version: plan.version,
                        total_steps: plan.steps.len(),
                        completed_steps: plan.completed_count(),
                    });
                }
            }
            Ok(summaries)
        })
    }

    fn delete_plan<'a>(&'a self, plan_id: &'a str) -> BoxFuture<'a, Result<bool>> {
        Box::pin(async move {
            self.store
                .delete(PLAN_NAMESPACE, plan_id)
                .await
                .map_err(|e| crate::error::ReactError::Other(format!("delete_plan: {}", e)))
        })
    }

    fn search_plans<'a>(
        &'a self,
        query: &'a str,
        limit: usize,
    ) -> BoxFuture<'a, Result<Vec<PlanSummary>>> {
        Box::pin(async move {
            let results = self
                .store
                .search(PLAN_NAMESPACE, query, limit)
                .await
                .map_err(|e| crate::error::ReactError::Other(format!("search_plans: {}", e)))?;

            let mut summaries = Vec::new();
            for item in results {
                if let Ok(plan) = serde_json::from_value::<Plan>(item.value) {
                    summaries.push(PlanSummary {
                        id: plan.id.clone().unwrap_or_default(),
                        slug: plan.slug.clone(),
                        goal: plan.goal.clone(),
                        version: plan.version,
                        total_steps: plan.steps.len(),
                        completed_steps: plan.completed_count(),
                    });
                }
            }
            Ok(summaries)
        })
    }
}

/// Generate a readable slug from two random words (e.g. "quick-fox")
pub fn generate_plan_slug() -> String {
    const ADJECTIVES: &[&str] = &[
        "swift", "bold", "calm", "dark", "fast", "keen", "lucky", "neat", "pure", "quiet", "rare",
        "safe", "tidy", "vast", "warm", "wise", "zippy", "agile", "brave", "clean",
    ];
    const NOUNS: &[&str] = &[
        "fox", "wolf", "bear", "hawk", "lynx", "tiger", "eagle", "shark", "crane", "otter",
        "raven", "whale", "panda", "cobra", "falcon", "badger", "heron", "moose", "robin", "stoat",
    ];

    let adj = ADJECTIVES[fastrand::usize(0..ADJECTIVES.len())];
    let noun = NOUNS[fastrand::usize(0..NOUNS.len())];
    format!("{}-{}", adj, noun)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::memory::store::InMemoryStore;
    use echo_core::agent::{Plan, PlanStep};

    fn sample_plan() -> Plan {
        Plan::new(vec![
            PlanStep::new("Analyze code structure"),
            PlanStep::new("Optimize performance").with_dependencies(vec!["step_0".to_string()]),
        ])
        .with_goal("Performance optimization")
        .with_slug("test-plan")
    }

    #[tokio::test]
    async fn test_save_and_load_plan() {
        let store = Arc::new(InMemoryStore::new());
        let plan_store = SqlitePlanStore::new(store);
        let plan = sample_plan();

        plan_store.save_plan(&plan).await.unwrap();

        let plan_id = plan.id.as_deref().unwrap();
        let loaded = plan_store.load_plan(plan_id).await.unwrap();
        assert!(loaded.is_some());
        let loaded = loaded.unwrap();
        assert_eq!(loaded.steps.len(), 2);
        assert_eq!(loaded.goal.as_deref(), Some("Performance optimization"));
        assert_eq!(loaded.steps[1].dependencies, vec!["step_0"]);
    }

    #[tokio::test]
    async fn test_load_nonexistent_plan() {
        let store = Arc::new(InMemoryStore::new());
        let plan_store = SqlitePlanStore::new(store);
        let loaded = plan_store.load_plan("nonexistent").await.unwrap();
        assert!(loaded.is_none());
    }

    #[tokio::test]
    async fn test_load_plan_by_slug() {
        let store = Arc::new(InMemoryStore::new());
        let plan_store = SqlitePlanStore::new(store);
        let plan = sample_plan();

        plan_store.save_plan(&plan).await.unwrap();

        let loaded = plan_store.load_plan_by_slug("test-plan").await.unwrap();
        assert!(loaded.is_some());
        assert_eq!(loaded.unwrap().slug.as_deref(), Some("test-plan"));
    }

    #[tokio::test]
    async fn test_list_plans() {
        let store = Arc::new(InMemoryStore::new());
        let plan_store = SqlitePlanStore::new(store);

        let p1 = Plan::new(vec![PlanStep::new("step A")]).with_slug("plan-a");
        let p2 = Plan::new(vec![PlanStep::new("step B")]).with_slug("plan-b");
        plan_store.save_plan(&p1).await.unwrap();
        plan_store.save_plan(&p2).await.unwrap();

        let summaries = plan_store.list_plans(10).await.unwrap();
        assert_eq!(summaries.len(), 2);
    }

    #[tokio::test]
    async fn test_delete_plan() {
        let store = Arc::new(InMemoryStore::new());
        let plan_store = SqlitePlanStore::new(store);
        let plan = sample_plan();

        plan_store.save_plan(&plan).await.unwrap();
        let plan_id = plan.id.as_deref().unwrap();

        let deleted = plan_store.delete_plan(plan_id).await.unwrap();
        assert!(deleted);

        let loaded = plan_store.load_plan(plan_id).await.unwrap();
        assert!(loaded.is_none());
    }

    #[tokio::test]
    async fn test_generate_slug() {
        let slug = generate_plan_slug();
        assert!(slug.contains('-'));
        let parts: Vec<&str> = slug.split('-').collect();
        assert_eq!(parts.len(), 2);
    }
}