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}