echo_core 0.1.4

Core traits and types for the echo-agent framework
Documentation
//! Conversation persistence trait and data types
//!
//! Provides structured storage for conversation history with multi-user,
//! multi-agent isolation. Concrete implementation ([`SqliteConversationStore`])
//! lives in `echo_state`.

use crate::error::Result;
use futures::future::BoxFuture;
use serde::{Deserialize, Serialize};
/// Parameters for creating a new conversation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NewConversation {
    /// External unique ID (generated by frontend, e.g. `conv-1709000000-abc123`)
    pub conversation_id: String,
    /// User ID (multi-user isolation)
    #[serde(default = "default_user_id")]
    pub user_id: String,
    /// Agent type this conversation belongs to
    pub agent_type: Option<String>,
    /// Conversation title (first-turn summary)
    pub title: Option<String>,
}

fn default_user_id() -> String {
    "default".to_string()
}

/// Complete conversation record
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Conversation {
    /// Database auto-increment ID
    pub id: i64,
    /// External unique ID
    pub conversation_id: String,
    /// User ID
    pub user_id: String,
    /// Agent type
    pub agent_type: Option<String>,
    /// Conversation title
    pub title: Option<String>,
    /// Compressed context summary
    pub summary: Option<String>,
    /// Compression boundary: summary covers messages up to this id (inclusive)
    pub compressed_before_id: Option<i64>,
    /// Creation time
    pub created_at: String,
    /// Update time
    pub updated_at: String,
}

/// Conversation list entry (lightweight, no messages or summary)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConversationMeta {
    pub id: i64,
    pub conversation_id: String,
    pub user_id: String,
    pub title: Option<String>,
    pub message_count: usize,
    pub created_at: String,
    pub updated_at: String,
}

/// Persisted message (independent from LLM Message, with persistence fields)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredMessage {
    /// Database auto-increment ID (None for new messages)
    pub id: Option<i64>,
    /// Owning conversation external ID
    pub conversation_id: String,
    /// Role: user / assistant / tool / system
    pub role: String,
    /// Message content
    pub content: Option<String>,
    /// Attachment metadata (JSON)
    pub attachments_json: Option<String>,
    /// Tool call records (JSON)
    pub tool_calls_json: Option<String>,
    /// Tool execution result (JSON)
    pub tool_result_json: Option<String>,
    /// Creation time
    pub created_at: String,
}

/// List filter criteria
#[derive(Debug, Clone, Default)]
pub struct ConversationFilter {
    pub user_id: Option<String>,
    pub agent_type: Option<String>,
    pub limit: Option<usize>,
    pub offset: Option<usize>,
}

// ── Trait ─────────────────────────────────────────────────────────────────────

/// Conversation persistence store trait
///
/// Provides CRUD operations for conversations and messages, supporting different storage backends.
pub trait ConversationStore: Send + Sync {
    /// Create a new conversation
    fn create_conversation<'a>(
        &'a self,
        conv: NewConversation,
    ) -> BoxFuture<'a, Result<Conversation>>;

    /// Get conversation details
    fn get_conversation<'a>(
        &'a self,
        conversation_id: &'a str,
    ) -> BoxFuture<'a, Result<Option<Conversation>>>;

    /// List conversations (with filtering, sorted by updated_at DESC)
    fn list_conversations<'a>(
        &'a self,
        filter: ConversationFilter,
    ) -> BoxFuture<'a, Result<Vec<ConversationMeta>>>;

    /// Update conversation (title / summary / compression boundary)
    fn update_conversation<'a>(
        &'a self,
        conversation_id: &'a str,
        title: Option<&'a str>,
        summary: Option<&'a str>,
        compressed_before_id: Option<i64>,
    ) -> BoxFuture<'a, Result<()>>;

    /// Delete conversation (cascades to messages)
    fn delete_conversation<'a>(&'a self, conversation_id: &'a str) -> BoxFuture<'a, Result<()>>;

    /// Save messages (upsert mode: delete old messages first, then insert new ones)
    fn save_messages<'a>(
        &'a self,
        conversation_id: &'a str,
        messages: &'a [StoredMessage],
    ) -> BoxFuture<'a, Result<()>>;

    /// Get all messages for a conversation (sorted by id ASC)
    fn get_messages<'a>(
        &'a self,
        conversation_id: &'a str,
    ) -> BoxFuture<'a, Result<Vec<StoredMessage>>>;

    /// Get message count
    fn count_messages<'a>(&'a self, conversation_id: &'a str) -> BoxFuture<'a, Result<usize>>;

    /// Create conversation if not exists, return existing record if it does.
    fn ensure_conversation<'a>(
        &'a self,
        conv: NewConversation,
    ) -> BoxFuture<'a, Result<Conversation>> {
        Box::pin(async move {
            let conversation_id = conv.conversation_id.clone();
            if let Some(existing) = self.get_conversation(&conversation_id).await? {
                Ok(existing)
            } else {
                self.create_conversation(conv).await
            }
        })
    }
}