Skip to main content

behest_store/
lib.rs

1//! Persistence layer for sessions, embeddings, artifacts, and executions.
2//!
3//! This module defines four storage trait abstractions:
4//!
5//! - [`SessionStore`]: CRUD for conversation sessions and message history
6//! - [`EmbeddingStore`]: Vector persistence and nearest-neighbor search
7//! - [`ArtifactStore`]: Binary blob storage for files and attachments
8//! - [`ExecutionStore`]: Tool execution records, token usage tracking, and session stats
9//!
10//! Each trait has an in-memory implementation (always available) and
11//! feature-gated backend implementations for SQL databases, MongoDB,
12//! Redis, and object stores.
13//!
14//! # Example
15//!
16//! ```rust
17//! use behest_provider::{ContentPart, ModelName};
18//! use behest_store::memory::MemorySessionStore;
19//! use behest_store::{MessageRecord, MessageRole, Session, SessionStore};
20//! use uuid::Uuid;
21//!
22//! # async fn example() -> Result<(), behest_core::error::StorageError> {
23//! let store = MemorySessionStore::new();
24//!
25//! let session = Session::new("My Chat", ModelName::new("gpt-4"));
26//! let session = store.create_session(session).await?;
27//!
28//! let message = MessageRecord::new(
29//!     session.id,
30//!     MessageRole::User,
31//!     vec![ContentPart::text("Hello!")],
32//! );
33//! store.append_message(message).await?;
34//! # Ok(())
35//! # }
36//! ```
37
38use std::time::Duration;
39
40use async_trait::async_trait;
41use chrono::{DateTime, Utc};
42use serde::{Deserialize, Serialize};
43use serde_json::Value;
44use uuid::Uuid;
45
46use behest_core::error::StorageError;
47use behest_provider::{ContentPart, ModelName, TokenUsage, ToolCall};
48
49pub mod memory;
50pub(crate) mod util;
51
52#[cfg(any(
53    feature = "sqlx-postgres",
54    feature = "sqlx-mysql",
55    feature = "sqlx-sqlite"
56))]
57pub mod sql;
58
59#[cfg(feature = "mongodb")]
60pub mod mongodb;
61
62#[cfg(feature = "redis")]
63pub mod redis;
64
65#[cfg(feature = "object_store")]
66pub mod object;
67
68/// Convenience result alias for storage operations using [`StorageError`].
69pub type StoreResult<T> = std::result::Result<T, StorageError>;
70
71// ---------------------------------------------------------------------------
72// Pagination and filter types
73// ---------------------------------------------------------------------------
74
75/// Pagination parameters for paginated list operations.
76///
77/// Uses offset-based pagination with configurable limit and offset.
78/// Defaults to returning the first 100 items.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
80pub struct Pagination {
81    /// Maximum number of items to return.
82    pub limit: u32,
83    /// Number of items to skip (offset-based pagination).
84    pub offset: u32,
85}
86
87impl Default for Pagination {
88    fn default() -> Self {
89        Self {
90            limit: 100,
91            offset: 0,
92        }
93    }
94}
95
96/// Filter parameters for listing sessions.
97#[derive(Debug, Clone, Default, Serialize, Deserialize)]
98pub struct SessionFilter {
99    /// Filter by metadata key-value match (optional).
100    pub metadata_filter: Option<Value>,
101    /// Filter by created_at range start (inclusive).
102    pub created_after: Option<DateTime<Utc>>,
103    /// Filter by created_at range end (exclusive).
104    pub created_before: Option<DateTime<Utc>>,
105}
106
107// ---------------------------------------------------------------------------
108// Session types
109// ---------------------------------------------------------------------------
110
111/// Persisted conversation session metadata.
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
113pub struct Session {
114    /// Unique session identifier.
115    pub id: Uuid,
116    /// Human-readable session title.
117    pub title: String,
118    /// Model identifier associated with this session.
119    pub model: ModelName,
120    /// When the session was created.
121    pub created_at: DateTime<Utc>,
122    /// When the session was last updated.
123    pub updated_at: DateTime<Utc>,
124    /// Application-specific metadata.
125    pub metadata: Value,
126}
127
128impl Session {
129    /// Creates a new session with a generated UUIDv7 ID and current timestamps.
130    ///
131    /// The session starts with no metadata (`Value::Null`) and matching
132    /// `created_at` / `updated_at` timestamps.
133    #[must_use]
134    pub fn new(title: impl Into<String>, model: ModelName) -> Self {
135        let now = Utc::now();
136        Self {
137            id: Uuid::now_v7(),
138            title: title.into(),
139            model,
140            created_at: now,
141            updated_at: now,
142            metadata: Value::Null,
143        }
144    }
145
146    /// Sets application metadata on the session, consuming and returning the session.
147    #[must_use]
148    pub fn with_metadata(mut self, metadata: Value) -> Self {
149        self.metadata = metadata;
150        self
151    }
152}
153
154/// Metadata attached to compaction-related messages.
155///
156/// Compaction produces two messages:
157/// 1. A user message with `is_compaction = true` and `compaction_meta.tail_start_id`
158///    indicating where the retained tail begins.
159/// 2. An assistant message with `is_summary = true` containing the LLM-generated summary.
160///
161/// The `previous_compaction_id` and `summary_text` fields enable incremental
162/// summarization: prior compaction summaries are fed back to the compaction LLM
163/// so it can update the summary instead of regenerating from scratch.
164#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
165pub struct CompactionMeta {
166    /// The first message ID retained in the tail after this compaction.
167    pub tail_start_id: Option<Uuid>,
168    /// The message ID of the previous compaction user message, enabling incremental updates.
169    pub previous_compaction_id: Option<Uuid>,
170    /// The summary text generated by the compaction LLM (stored for reuse).
171    pub summary_text: Option<String>,
172}
173
174impl CompactionMeta {
175    /// Creates compaction metadata for a new compaction user message.
176    ///
177    /// Sets `tail_start_id` to the given ID; `previous_compaction_id`
178    /// and `summary_text` are left as `None`.
179    #[must_use]
180    pub fn new(tail_start_id: Uuid) -> Self {
181        Self {
182            tail_start_id: Some(tail_start_id),
183            previous_compaction_id: None,
184            summary_text: None,
185        }
186    }
187
188    /// Sets the previous compaction ID for incremental summarization, consuming and returning self.
189    #[must_use]
190    pub fn with_previous(mut self, previous_id: Uuid) -> Self {
191        self.previous_compaction_id = Some(previous_id);
192        self
193    }
194
195    /// Sets the summary text, consuming and returning self.
196    #[must_use]
197    pub fn with_summary(mut self, summary: String) -> Self {
198        self.summary_text = Some(summary);
199        self
200    }
201}
202
203/// Persisted message exchange within a session.
204///
205/// Each message records a single turn in the conversation: the role (user,
206/// assistant, system, tool), its content parts, optional tool calls or tool
207/// results, token usage, and compaction state.
208#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
209pub struct MessageRecord {
210    /// Unique message identifier.
211    pub id: Uuid,
212    /// Session this message belongs to.
213    pub session_id: Uuid,
214    /// Message role.
215    pub role: MessageRole,
216    /// Message content parts.
217    pub content: Vec<ContentPart>,
218    /// Tool calls made by the assistant, if any.
219    #[serde(default, skip_serializing_if = "Vec::is_empty")]
220    pub tool_calls: Vec<ToolCall>,
221    /// Tool call ID for tool result messages.
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub tool_call_id: Option<String>,
224    /// Tool name for tool result messages.
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub tool_name: Option<String>,
227    /// Token usage associated with this exchange.
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub usage: Option<TokenUsage>,
230    /// When the message was created.
231    pub created_at: DateTime<Utc>,
232    /// Whether this message is a compaction task (user message triggering compression).
233    #[serde(default)]
234    pub is_compaction: bool,
235    /// Whether this message contains a compaction summary (assistant response to compaction).
236    #[serde(default)]
237    pub is_summary: bool,
238    /// Compaction metadata, populated only for compaction-related messages.
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub compaction_meta: Option<CompactionMeta>,
241}
242
243impl MessageRecord {
244    /// Creates a new message record with a generated UUIDv7 ID and current timestamp.
245    ///
246    /// All optional fields (`tool_calls`, `tool_call_id`, `tool_name`, `usage`,
247    /// compaction flags) are initialized to their default/absent state.
248    #[must_use]
249    pub fn new(session_id: Uuid, role: MessageRole, content: Vec<ContentPart>) -> Self {
250        Self {
251            id: Uuid::now_v7(),
252            session_id,
253            role,
254            content,
255            tool_calls: Vec::new(),
256            tool_call_id: None,
257            tool_name: None,
258            usage: None,
259            created_at: Utc::now(),
260            is_compaction: false,
261            is_summary: false,
262            compaction_meta: None,
263        }
264    }
265
266    /// Sets tool call metadata on a tool result message, consuming and returning self.
267    ///
268    /// Use this when the message represents a tool execution result rather than
269    /// an assistant message that initiates tool calls.
270    #[must_use]
271    pub fn with_tool_result(mut self, call_id: String, name: String) -> Self {
272        self.tool_call_id = Some(call_id);
273        self.tool_name = Some(name);
274        self
275    }
276
277    /// Sets tool calls on the message record, consuming and returning self.
278    ///
279    /// Use this when the assistant message initiates one or more tool calls.
280    #[must_use]
281    pub fn with_tool_calls(mut self, tool_calls: Vec<ToolCall>) -> Self {
282        self.tool_calls = tool_calls;
283        self
284    }
285
286    /// Sets token usage on the message record, consuming and returning self.
287    #[must_use]
288    pub fn with_usage(mut self, usage: TokenUsage) -> Self {
289        self.usage = Some(usage);
290        self
291    }
292
293    /// Marks this message as a compaction task user message, consuming and returning self.
294    #[must_use]
295    pub fn with_compaction(mut self, meta: CompactionMeta) -> Self {
296        self.is_compaction = true;
297        self.compaction_meta = Some(meta);
298        self
299    }
300
301    /// Marks this message as a compaction summary assistant message, consuming and returning self.
302    #[must_use]
303    pub fn with_summary(mut self, meta: CompactionMeta) -> Self {
304        self.is_summary = true;
305        self.compaction_meta = Some(meta);
306        self
307    }
308}
309
310impl From<&behest_provider::Message> for MessageRole {
311    fn from(message: &behest_provider::Message) -> Self {
312        match message {
313            behest_provider::Message::System { .. } => Self::System,
314            behest_provider::Message::User { .. } => Self::User,
315            behest_provider::Message::Assistant { .. } => Self::Assistant,
316            behest_provider::Message::Tool { .. } => Self::Tool,
317            _ => Self::User,
318        }
319    }
320}
321
322/// Role tag for a persisted message.
323#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
324#[serde(rename_all = "snake_case")]
325#[non_exhaustive]
326pub enum MessageRole {
327    /// System instruction.
328    System,
329    /// User input.
330    User,
331    /// Assistant response.
332    Assistant,
333    /// Tool result.
334    Tool,
335}
336
337// ---------------------------------------------------------------------------
338// Embedding types
339// ---------------------------------------------------------------------------
340
341/// Embedding record with a dense vector and associated metadata.
342#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
343pub struct EmbeddingRecord {
344    /// Unique record identifier.
345    pub id: Uuid,
346    /// Optional session association.
347    pub session_id: Option<Uuid>,
348    /// Model that produced the embedding.
349    pub model: String,
350    /// Dense embedding vector (list of `f32` components).
351    pub vector: Vec<f32>,
352    /// Application-specific metadata.
353    pub metadata: Value,
354    /// When the record was created.
355    pub created_at: DateTime<Utc>,
356}
357
358impl EmbeddingRecord {
359    /// Creates a new embedding record with a generated UUIDv7 ID and current timestamp.
360    ///
361    /// `session_id` and `metadata` are initialized to `None` and `Value::Null` respectively.
362    #[must_use]
363    pub fn new(model: impl Into<String>, vector: Vec<f32>) -> Self {
364        Self {
365            id: Uuid::now_v7(),
366            session_id: None,
367            model: model.into(),
368            vector,
369            metadata: Value::Null,
370            created_at: Utc::now(),
371        }
372    }
373
374    /// Associates this embedding record with a session, consuming and returning self.
375    #[must_use]
376    pub fn with_session(mut self, session_id: Uuid) -> Self {
377        self.session_id = Some(session_id);
378        self
379    }
380
381    /// Sets metadata on the embedding record, consuming and returning self.
382    #[must_use]
383    pub fn with_metadata(mut self, metadata: Value) -> Self {
384        self.metadata = metadata;
385        self
386    }
387}
388
389/// Embedding search result pairing a matching record with its similarity score.
390#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
391pub struct ScoredEmbedding {
392    /// The matching embedding record.
393    pub record: EmbeddingRecord,
394    /// Similarity score (higher means closer match).
395    pub score: f32,
396}
397
398// ---------------------------------------------------------------------------
399// Artifact types
400// ---------------------------------------------------------------------------
401
402/// Binary artifact stored by the agent runtime, such as files, images, or attachments.
403#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
404pub struct Artifact {
405    /// Unique artifact identifier.
406    pub id: Uuid,
407    /// Optional session association.
408    pub session_id: Option<Uuid>,
409    /// Human-readable artifact name.
410    pub name: String,
411    /// MIME content type.
412    pub content_type: String,
413    /// Raw binary data.
414    #[serde(with = "base64_bytes")]
415    pub data: Vec<u8>,
416    /// Application-specific metadata.
417    pub metadata: Value,
418    /// When the artifact was created.
419    pub created_at: DateTime<Utc>,
420}
421
422impl Artifact {
423    /// Creates a new artifact with a generated UUIDv7 ID and current timestamp.
424    ///
425    /// `session_id` and `metadata` are initialized to `None` and `Value::Null` respectively.
426    #[must_use]
427    pub fn new(name: impl Into<String>, content_type: impl Into<String>, data: Vec<u8>) -> Self {
428        Self {
429            id: Uuid::now_v7(),
430            session_id: None,
431            name: name.into(),
432            content_type: content_type.into(),
433            data,
434            metadata: Value::Null,
435            created_at: Utc::now(),
436        }
437    }
438
439    /// Associates this artifact with a session, consuming and returning self.
440    #[must_use]
441    pub fn with_session(mut self, session_id: Uuid) -> Self {
442        self.session_id = Some(session_id);
443        self
444    }
445
446    /// Sets metadata on the artifact, consuming and returning self.
447    #[must_use]
448    pub fn with_metadata(mut self, metadata: Value) -> Self {
449        self.metadata = metadata;
450        self
451    }
452}
453
454/// Base64 encoding/decoding module for `Vec<u8>` serialization.
455mod base64_bytes {
456    use serde::{Deserialize, Deserializer, Serialize, Serializer};
457
458    pub(super) fn serialize<S>(data: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
459    where
460        S: Serializer,
461    {
462        use base64::Engine as _;
463        base64::engine::general_purpose::STANDARD
464            .encode(data)
465            .serialize(serializer)
466    }
467
468    pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
469    where
470        D: Deserializer<'de>,
471    {
472        use base64::Engine as _;
473        let s = String::deserialize(deserializer)?;
474        base64::engine::general_purpose::STANDARD
475            .decode(&s)
476            .map_err(serde::de::Error::custom)
477    }
478}
479
480// ---------------------------------------------------------------------------
481// Traits
482// ---------------------------------------------------------------------------
483
484/// Persists conversation sessions and their message history.
485///
486/// Implementations must be `Send + Sync` to support concurrent access
487/// from the agent runtime. Backends are selected at compile time via
488/// Cargo feature flags.
489#[async_trait]
490pub trait SessionStore: Send + Sync {
491    /// Persists a new session and returns it with server-assigned fields.
492    async fn create_session(&self, session: Session) -> StoreResult<Session>;
493
494    /// Returns all sessions ordered by most recently updated (descending).
495    async fn list_sessions(&self) -> StoreResult<Vec<Session>>;
496
497    /// Returns a session by identifier, or `None` if not found.
498    async fn get_session(&self, id: &Uuid) -> StoreResult<Option<Session>>;
499
500    /// Deletes a session and all related data (cascading delete).
501    ///
502    /// Implementations MUST cascade the deletion to at minimum:
503    /// - All messages belonging to the session
504    /// - All tool executions belonging to the session
505    /// - All usage records belonging to the session
506    ///
507    /// Embedding associations should be set to `NULL` or deleted.
508    /// Artifacts associated with the session should be deleted.
509    ///
510    /// This operation is idempotent: deleting a non-existent session
511    /// succeeds silently.
512    async fn delete_session(&self, id: &Uuid) -> StoreResult<()>;
513
514    /// Updates a session's title and/or model.
515    ///
516    /// Fields set to `None` are left unchanged.
517    ///
518    /// # Errors
519    ///
520    /// Returns [`StorageError::NotFound`] when the session does not exist.
521    /// Default implementation returns [`StorageError::BackendError`]; backends
522    /// should override this with a proper implementation.
523    async fn update_session(
524        &self,
525        id: &Uuid,
526        title: &str,
527        model: Option<&ModelName>,
528    ) -> StoreResult<Session> {
529        let _ = (id, title, model);
530        Err(StorageError::BackendError {
531            backend: "session".to_owned(),
532            message: "update_session not implemented for this backend".to_owned(),
533            source: None,
534        })
535    }
536
537    /// Appends a message to a session's history.
538    async fn append_message(&self, message: MessageRecord) -> StoreResult<MessageRecord>;
539
540    /// Returns all messages for a session ordered by creation time.
541    async fn list_messages(&self, session_id: &Uuid) -> StoreResult<Vec<MessageRecord>>;
542
543    /// Updates token usage on an existing message record.
544    async fn update_usage(&self, message_id: &Uuid, usage: TokenUsage) -> StoreResult<()>;
545
546    /// Returns sessions matching the filter, paginated.
547    ///
548    /// Default implementation calls [`list_sessions`](SessionStore::list_sessions)
549    /// and filters/paginates in memory. Backends should override with native
550    /// implementations for efficiency.
551    async fn list_sessions_paginated(
552        &self,
553        pagination: Pagination,
554        filter: SessionFilter,
555    ) -> StoreResult<Vec<Session>> {
556        let sessions = self.list_sessions().await?;
557        let filtered: Vec<Session> = sessions
558            .into_iter()
559            .filter(|s| {
560                if let Some(ref after) = filter.created_after
561                    && s.created_at < *after
562                {
563                    return false;
564                }
565                if let Some(ref before) = filter.created_before
566                    && s.created_at >= *before
567                {
568                    return false;
569                }
570                if let Some(ref meta_filter) = filter.metadata_filter {
571                    return s.metadata == *meta_filter;
572                }
573                true
574            })
575            .skip(pagination.offset as usize)
576            .take(pagination.limit as usize)
577            .collect();
578        Ok(filtered)
579    }
580
581    /// Returns messages for a session, paginated.
582    ///
583    /// Default implementation calls [`list_messages`](SessionStore::list_messages)
584    /// and paginates in memory. Backends should override with native
585    /// implementations for efficiency.
586    async fn list_messages_paginated(
587        &self,
588        session_id: &Uuid,
589        pagination: Pagination,
590    ) -> StoreResult<Vec<MessageRecord>> {
591        let messages = self.list_messages(session_id).await?;
592        Ok(messages
593            .into_iter()
594            .skip(pagination.offset as usize)
595            .take(pagination.limit as usize)
596            .collect())
597    }
598
599    /// Checks connectivity to the backend.
600    ///
601    /// Returns `Ok(())` if the backend is reachable and responsive.
602    /// Default implementation returns `Ok(())`.
603    async fn health_check(&self) -> StoreResult<()> {
604        Ok(())
605    }
606
607    /// Returns the most recent compaction user message for a session.
608    ///
609    /// Used for incremental summarization: prior compaction summaries are
610    /// fed back to the compaction LLM so it can update the summary instead
611    /// of regenerating from scratch.
612    ///
613    /// Default implementation iterates [`list_messages`](SessionStore::list_messages)
614    /// in reverse; backends should override with a native indexed query.
615    async fn get_latest_compaction(&self, session_id: &Uuid) -> StoreResult<Option<MessageRecord>> {
616        let messages = self.list_messages(session_id).await?;
617        Ok(messages.into_iter().rev().find(|m| m.is_compaction))
618    }
619
620    /// Marks a message as compacted, setting its `is_summary` flag.
621    ///
622    /// Used after the compaction LLM produces a summary to signal that
623    /// older tool outputs upstream of this message are eligible for pruning.
624    ///
625    /// Default implementation is a no-op; backends that need to track
626    /// compaction state for pruning should override.
627    async fn mark_compacted(&self, _message_id: &Uuid) -> StoreResult<()> {
628        Ok(())
629    }
630}
631
632/// Persists embedding vectors and supports nearest-neighbor search.
633///
634/// Implementations use cosine similarity for ranking results (higher score = closer match).
635/// Backends include in-memory HashMap and PostgreSQL with pgvector.
636#[async_trait]
637pub trait EmbeddingStore: Send + Sync {
638    /// Inserts or updates an embedding record.
639    ///
640    /// If a record with the same ID already exists, it is replaced.
641    /// Returns the stored record on success.
642    async fn upsert(&self, record: EmbeddingRecord) -> StoreResult<EmbeddingRecord>;
643
644    /// Returns the `limit` nearest neighbors to the query vector.
645    ///
646    /// Each result includes the record and its similarity score (higher is closer).
647    async fn search(&self, query: &[f32], limit: usize) -> StoreResult<Vec<ScoredEmbedding>>;
648
649    /// Deletes an embedding by identifier.
650    ///
651    /// Returns `Ok(())` even when the ID does not exist (idempotent).
652    async fn delete(&self, id: &Uuid) -> StoreResult<()>;
653
654    /// Deletes all embeddings associated with a session.
655    ///
656    /// Returns the number of records deleted.
657    async fn delete_by_session(&self, session_id: &Uuid) -> StoreResult<u64>;
658}
659
660/// Stores binary artifacts such as files, images, and attachments.
661///
662/// Backends include in-memory HashMap, local filesystem, and Amazon S3-compatible
663/// object stores (via the `object_store` crate).
664#[async_trait]
665pub trait ArtifactStore: Send + Sync {
666    /// Stores an artifact and returns it with server-assigned fields.
667    async fn put(&self, artifact: Artifact) -> StoreResult<Artifact>;
668
669    /// Retrieves an artifact by identifier, or `None` if not found.
670    async fn get(&self, id: &Uuid) -> StoreResult<Option<Artifact>>;
671
672    /// Deletes an artifact by identifier (idempotent).
673    async fn delete(&self, id: &Uuid) -> StoreResult<()>;
674
675    /// Lists all artifacts associated with a session.
676    async fn list_by_session(&self, session_id: &Uuid) -> StoreResult<Vec<Artifact>>;
677
678    /// Deletes all artifacts associated with a session.
679    ///
680    /// Returns the number of artifacts deleted.
681    /// Default implementation is a no-op returning 0.
682    async fn delete_by_session(&self, session_id: &Uuid) -> StoreResult<u64> {
683        let _ = session_id;
684        Ok(0)
685    }
686}
687
688// ---------------------------------------------------------------------------
689// Execution types
690// ---------------------------------------------------------------------------
691
692/// Persisted record of a single tool invocation.
693///
694/// Captures the full lifecycle: input arguments, output result, execution status,
695/// wall-clock duration, and any error message on failure.
696#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
697pub struct ToolExecution {
698    /// Unique execution identifier.
699    pub id: Uuid,
700    /// Session this execution belongs to.
701    pub session_id: Uuid,
702    /// Message that triggered the tool call.
703    pub message_id: Uuid,
704    /// Tool call identifier from the provider.
705    pub call_id: String,
706    /// Tool name that was executed.
707    pub tool_name: String,
708    /// JSON arguments passed to the tool.
709    pub arguments: Value,
710    /// Tool output value, if execution succeeded.
711    pub result: Option<Value>,
712    /// Execution status.
713    pub status: ToolExecutionStatus,
714    /// Human-readable error message when status is `Failed`.
715    pub error: Option<String>,
716    /// Wall-clock execution duration.
717    #[serde(with = "duration_millis")]
718    pub duration: Duration,
719    /// When the execution started.
720    pub created_at: DateTime<Utc>,
721}
722
723impl ToolExecution {
724    /// Creates a new tool execution record with a generated UUIDv7 ID and current timestamp.
725    ///
726    /// The execution starts with `Pending` status, no result, no error,
727    /// and zero duration.
728    #[must_use]
729    pub fn new(
730        session_id: Uuid,
731        message_id: Uuid,
732        call_id: impl Into<String>,
733        tool_name: impl Into<String>,
734        arguments: Value,
735    ) -> Self {
736        Self {
737            id: Uuid::now_v7(),
738            session_id,
739            message_id,
740            call_id: call_id.into(),
741            tool_name: tool_name.into(),
742            arguments,
743            result: None,
744            status: ToolExecutionStatus::Pending,
745            error: None,
746            duration: Duration::ZERO,
747            created_at: Utc::now(),
748        }
749    }
750
751    /// Marks the execution as successful with the given result and duration, consuming and returning self.
752    #[must_use]
753    pub fn with_success(mut self, result: Value, duration: Duration) -> Self {
754        self.result = Some(result);
755        self.status = ToolExecutionStatus::Success;
756        self.duration = duration;
757        self
758    }
759
760    /// Marks the execution as failed with the given error message and duration, consuming and returning self.
761    #[must_use]
762    pub fn with_failure(mut self, error: impl Into<String>, duration: Duration) -> Self {
763        self.error = Some(error.into());
764        self.status = ToolExecutionStatus::Failed;
765        self.duration = duration;
766        self
767    }
768}
769
770/// Outcome of a tool execution.
771#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
772#[serde(rename_all = "snake_case")]
773#[non_exhaustive]
774pub enum ToolExecutionStatus {
775    /// Execution has not started yet.
776    Pending,
777    /// Execution completed successfully.
778    Success,
779    /// Execution failed with an error.
780    Failed,
781}
782
783/// Detailed token usage record for a single provider interaction.
784///
785/// Tracks per-request token consumption broken down by provider, model,
786/// session, and message for cost accounting and analytics.
787#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
788pub struct UsageRecord {
789    /// Unique record identifier.
790    pub id: Uuid,
791    /// Session this usage belongs to.
792    pub session_id: Uuid,
793    /// Message that produced this usage.
794    pub message_id: Uuid,
795    /// Provider that served the request.
796    pub provider: String,
797    /// Model that served the request.
798    pub model: String,
799    /// Number of input (prompt) tokens consumed.
800    pub input_tokens: u64,
801    /// Number of output (completion) tokens consumed.
802    pub output_tokens: u64,
803    /// Total token count.
804    pub total_tokens: u64,
805    /// When the usage was recorded.
806    pub created_at: DateTime<Utc>,
807}
808
809impl UsageRecord {
810    /// Creates a usage record from a provider response with a generated UUIDv7 ID and current timestamp.
811    ///
812    /// The token counts are extracted from the given [`TokenUsage`] struct.
813    #[must_use]
814    pub fn new(
815        session_id: Uuid,
816        message_id: Uuid,
817        provider: impl Into<String>,
818        model: impl Into<String>,
819        usage: TokenUsage,
820    ) -> Self {
821        Self {
822            id: Uuid::now_v7(),
823            session_id,
824            message_id,
825            provider: provider.into(),
826            model: model.into(),
827            input_tokens: usage.input_tokens,
828            output_tokens: usage.output_tokens,
829            total_tokens: usage.total_tokens,
830            created_at: Utc::now(),
831        }
832    }
833}
834
835/// Pre-computed or live-aggregated statistics for a session.
836///
837/// Provides a summary view including message count, tool execution counts
838/// (total / success / failure), cumulative token usage, and average tool
839/// execution duration.
840#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
841pub struct SessionStats {
842    /// Session identifier.
843    pub session_id: Uuid,
844    /// Total number of messages in the session.
845    pub message_count: u64,
846    /// Total number of tool executions.
847    pub tool_call_count: u64,
848    /// Number of successful tool executions.
849    pub tool_success_count: u64,
850    /// Number of failed tool executions.
851    pub tool_failure_count: u64,
852    /// Cumulative input tokens across all provider calls.
853    pub total_input_tokens: u64,
854    /// Cumulative output tokens across all provider calls.
855    pub total_output_tokens: u64,
856    /// Cumulative total tokens.
857    pub total_tokens: u64,
858    /// Average tool execution duration in milliseconds.
859    pub avg_tool_duration_ms: u64,
860}
861
862impl SessionStats {
863    /// Creates an all-zero stats struct for a session.
864    ///
865    /// All counters and the average duration are initialized to zero.
866    #[must_use]
867    pub fn empty(session_id: Uuid) -> Self {
868        Self {
869            session_id,
870            message_count: 0,
871            tool_call_count: 0,
872            tool_success_count: 0,
873            tool_failure_count: 0,
874            total_input_tokens: 0,
875            total_output_tokens: 0,
876            total_tokens: 0,
877            avg_tool_duration_ms: 0,
878        }
879    }
880}
881
882/// Persists tool execution records and token usage, and provides session analytics.
883///
884/// Implementations must be `Send + Sync`. Backends include in-memory, PostgreSQL,
885/// MySQL, and SQLite (via `sqlx`).
886#[async_trait]
887pub trait ExecutionStore: Send + Sync {
888    /// Records a tool execution and returns it with server-assigned fields.
889    async fn record_execution(&self, execution: ToolExecution) -> StoreResult<ToolExecution>;
890
891    /// Returns all tool executions for a session ordered by creation time (ascending).
892    async fn list_executions(&self, session_id: &Uuid) -> StoreResult<Vec<ToolExecution>>;
893
894    /// Returns all tool executions for a specific message ordered by creation time.
895    async fn list_executions_by_message(
896        &self,
897        message_id: &Uuid,
898    ) -> StoreResult<Vec<ToolExecution>>;
899
900    /// Records token usage for a single provider interaction.
901    async fn record_usage(&self, record: UsageRecord) -> StoreResult<UsageRecord>;
902
903    /// Returns all usage records for a session ordered by creation time (ascending).
904    async fn list_usage(&self, session_id: &Uuid) -> StoreResult<Vec<UsageRecord>>;
905
906    /// Computes aggregate statistics for a session.
907    ///
908    /// Returns pre-computed or live-aggregated stats including message counts,
909    /// tool call counts, and cumulative token usage.
910    async fn session_stats(&self, session_id: &Uuid) -> StoreResult<SessionStats>;
911
912    /// Deletes all executions and usage records for a session.
913    ///
914    /// Returns the number of records deleted.
915    /// Default implementation is a no-op returning 0.
916    async fn delete_by_session(&self, session_id: &Uuid) -> StoreResult<u64> {
917        let _ = session_id;
918        Ok(0)
919    }
920}
921
922/// Serde helper for `Duration` as milliseconds.
923mod duration_millis {
924    use serde::{Deserialize, Deserializer, Serialize, Serializer};
925    use std::time::Duration;
926
927    pub(super) fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
928    where
929        S: Serializer,
930    {
931        duration.as_millis().serialize(serializer)
932    }
933
934    pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
935    where
936        D: Deserializer<'de>,
937    {
938        let millis = u64::deserialize(deserializer)?;
939        Ok(Duration::from_millis(millis))
940    }
941}