Skip to main content

agent_sdk/stores/
sqlite.rs

1//! Embedded, single-file durable session store backed by SQLite.
2//!
3//! [`SqliteStore`] implements all four SDK store traits — [`MessageStore`],
4//! [`StateStore`], [`EventStore`], and [`ToolExecutionStore`] — over one
5//! on-disk SQLite database. Unlike the volatile `InMemory*` stores, the data it
6//! records survives the process: an in-process agent can persist its
7//! conversation history, state checkpoints, turn events, and tool-execution
8//! ledger and pick the conversation back up after a restart.
9//!
10//! This is gated behind the `sqlite` cargo feature so the default build pulls
11//! no SQLite dependency. The driver ([`rusqlite`] with the bundled SQLite
12//! amalgamation) is synchronous, so every database call runs inside
13//! [`tokio::task::spawn_blocking`] to keep the async runtime unblocked.
14//!
15//! # Resume semantics
16//!
17//! Durability is keyed by the **file path** plus the [`ThreadId`]. Reopen the
18//! same path and run with the same `ThreadId` and the agent continues exactly
19//! where it left off — the message history, the latest state checkpoint, every
20//! stored turn's events, and the tool-execution records are all still there. A
21//! fresh `ThreadId` against the same file starts an independent conversation in
22//! the same database.
23//!
24//! # One store, four traits
25//!
26//! [`SqliteStore`] is cheap to [`Clone`] (it shares one
27//! `Arc<Mutex<Connection>>`, not a connection pool), so the same store can back
28//! every slot on the builder:
29//!
30//! ```no_run
31//! use std::sync::Arc;
32//! use agent_sdk::{builder, DefaultHooks, EventStore, SqliteStore, ThreadId};
33//! # use agent_sdk::providers::AnthropicProvider;
34//!
35//! # fn example() -> anyhow::Result<()> {
36//! // Same file + same ThreadId across restarts == resumed conversation.
37//! let store = SqliteStore::open("agent.db")?;
38//! let events: Arc<dyn EventStore> = Arc::new(store.clone());
39//!
40//! // Custom stores require `build_with_stores()` (and explicit hooks).
41//! let agent = builder::<()>()
42//!     .provider(AnthropicProvider::from_env())
43//!     .hooks(DefaultHooks)
44//!     .message_store(store.clone())
45//!     .state_store(store.clone())
46//!     .event_store(events)
47//!     .execution_store(store)
48//!     .build_with_stores();
49//! # let _ = (agent, ThreadId::new());
50//! # Ok(())
51//! # }
52//! ```
53
54use std::path::Path;
55use std::sync::{Arc, Mutex};
56
57use agent_sdk_foundation::events::AgentEventEnvelope;
58use agent_sdk_foundation::llm;
59use agent_sdk_foundation::types::{AgentState, ThreadId, ToolExecution};
60use anyhow::{Context, Result};
61use async_trait::async_trait;
62use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
63
64use super::{EventStore, MessageStore, StateStore, StoredTurnEvents, ToolExecutionStore};
65
66/// How long a blocked writer waits for a competing connection to release its
67/// lock before `SQLite` returns `SQLITE_BUSY`. The headline use case
68/// (restart-and-resume) can briefly contend with a lingering process still
69/// checkpointing the WAL, so we wait a few seconds rather than failing fast.
70const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
71
72/// On-disk format version stamped into `PRAGMA user_version`. Bump when the
73/// table layout changes and add a migration path in [`SqliteStore::open`].
74const SCHEMA_VERSION: i64 = 1;
75
76/// Schema applied on open. `CREATE TABLE IF NOT EXISTS` makes opening an
77/// existing database a no-op, which is exactly the resume path.
78const SCHEMA: &str = "\
79CREATE TABLE IF NOT EXISTS agent_messages (
80    id        INTEGER PRIMARY KEY AUTOINCREMENT,
81    thread_id TEXT NOT NULL,
82    payload   TEXT NOT NULL
83);
84CREATE INDEX IF NOT EXISTS idx_agent_messages_thread
85    ON agent_messages (thread_id, id);
86
87CREATE TABLE IF NOT EXISTS agent_states (
88    thread_id TEXT PRIMARY KEY,
89    payload   TEXT NOT NULL
90);
91
92CREATE TABLE IF NOT EXISTS agent_event_turns (
93    thread_id TEXT NOT NULL,
94    turn      INTEGER NOT NULL,
95    finished  INTEGER NOT NULL DEFAULT 0,
96    PRIMARY KEY (thread_id, turn)
97);
98
99CREATE TABLE IF NOT EXISTS agent_events (
100    id        INTEGER PRIMARY KEY AUTOINCREMENT,
101    thread_id TEXT NOT NULL,
102    turn      INTEGER NOT NULL,
103    payload   TEXT NOT NULL
104);
105CREATE INDEX IF NOT EXISTS idx_agent_events_thread_turn
106    ON agent_events (thread_id, turn, id);
107
108CREATE TABLE IF NOT EXISTS agent_tool_executions (
109    tool_call_id TEXT PRIMARY KEY,
110    operation_id TEXT,
111    payload      TEXT NOT NULL
112);
113CREATE INDEX IF NOT EXISTS idx_agent_tool_executions_operation
114    ON agent_tool_executions (operation_id);
115";
116
117/// Single-file SQLite-backed implementation of every SDK store trait.
118///
119/// See the module docs for resume semantics and a wiring example.
120/// Cloning shares the same single `Arc<Mutex<Connection>>` (not a pool), so a
121/// clone handed to the agent builder observes everything the kept handle
122/// records (and vice versa); all database calls serialize on that one mutex.
123#[derive(Clone)]
124pub struct SqliteStore {
125    conn: Arc<Mutex<Connection>>,
126}
127
128impl SqliteStore {
129    /// Open (creating if absent) the `SQLite` database at `path` and ensure the
130    /// store schema exists.
131    ///
132    /// Reopening an existing file is the resume path: the schema creation is a
133    /// no-op and all previously persisted rows remain available.
134    ///
135    /// This constructor is synchronous — it opens the file, converts it to
136    /// WAL, and applies the schema on the calling thread (waiting up to five
137    /// seconds if another process holds the write lock). Call it during
138    /// startup, or wrap it in `spawn_blocking` on a latency-sensitive async
139    /// runtime.
140    ///
141    /// # Errors
142    /// Returns an error if the database cannot be opened, the schema cannot
143    /// be initialized, or the file carries a schema version newer than this
144    /// SDK understands.
145    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
146        let path = path.as_ref();
147        let conn = Connection::open(path)
148            .with_context(|| format!("failed to open sqlite database at {}", path.display()))?;
149        // `busy_timeout` first: it makes every subsequent lock-taking
150        // statement — including the WAL conversion below, the very first one
151        // — wait for a competing connection instead of failing fast with
152        // `SQLITE_BUSY`. Important for restart-and-resume, where a lingering
153        // process may still be checkpointing the WAL when the replacement
154        // opens the file.
155        conn.busy_timeout(BUSY_TIMEOUT)
156            .context("failed to set sqlite busy timeout")?;
157        // WAL lets readers run concurrently with a writer.
158        conn.pragma_update(None, "journal_mode", "WAL")
159            .context("failed to enable WAL journal mode")?;
160        // Validate the on-disk format version BEFORE touching the schema: a
161        // file stamped by a newer SDK must fail safely here, not have v1 DDL
162        // re-applied over (or v1 queries run against) a format this binary
163        // does not understand. 0 means "fresh or pre-versioning", which is
164        // the v1 layout this store initializes below.
165        let version: i64 = conn
166            .pragma_query_value(None, "user_version", |row| row.get(0))
167            .context("failed to read sqlite user_version")?;
168        if version > SCHEMA_VERSION {
169            anyhow::bail!(
170                "sqlite store at {} has schema version {version}, newer than the \
171                 version {SCHEMA_VERSION} this SDK supports; upgrade the SDK (or point at a \
172                 different file)",
173                path.display()
174            );
175        }
176        conn.execute_batch(SCHEMA)
177            .context("failed to initialize sqlite store schema")?;
178        if version == 0 {
179            conn.pragma_update(None, "user_version", SCHEMA_VERSION)
180                .context("failed to stamp sqlite user_version")?;
181        }
182        Ok(Self {
183            conn: Arc::new(Mutex::new(conn)),
184        })
185    }
186
187    /// Run a synchronous database closure on a blocking thread.
188    ///
189    /// The connection is synchronous and `!Sync`, so it lives behind a
190    /// [`Mutex`]; the closure runs inside [`spawn_blocking`](tokio::task::spawn_blocking)
191    /// to avoid stalling the async runtime.
192    async fn with_conn<T>(
193        &self,
194        f: impl FnOnce(&mut Connection) -> Result<T> + Send + 'static,
195    ) -> Result<T>
196    where
197        T: Send + 'static,
198    {
199        let conn = Arc::clone(&self.conn);
200        tokio::task::spawn_blocking(move || {
201            let mut guard = conn
202                .lock()
203                .ok()
204                .context("sqlite connection mutex poisoned")?;
205            f(&mut guard)
206        })
207        .await
208        .context("sqlite blocking task failed to join")?
209    }
210
211    /// Shared upsert for [`ToolExecutionStore::record_execution`] and
212    /// [`ToolExecutionStore::update_execution`]: both write the full record,
213    /// keyed by `tool_call_id`, refreshing the `operation_id` index column.
214    async fn upsert_execution(&self, execution: ToolExecution) -> Result<()> {
215        let tool_call_id = execution.tool_call_id.clone();
216        let operation_id = execution.operation_id.clone();
217        let payload =
218            serde_json::to_string(&execution).context("failed to encode tool execution")?;
219        self.with_conn(move |conn| {
220            conn.execute(
221                "INSERT INTO agent_tool_executions (tool_call_id, operation_id, payload)
222                 VALUES (?1, ?2, ?3)
223                 ON CONFLICT(tool_call_id)
224                 DO UPDATE SET operation_id = excluded.operation_id, payload = excluded.payload",
225                params![tool_call_id, operation_id, payload],
226            )
227            .context("failed to upsert tool execution")?;
228            Ok(())
229        })
230        .await
231    }
232}
233
234#[async_trait]
235impl MessageStore for SqliteStore {
236    async fn append(&self, thread_id: &ThreadId, message: llm::Message) -> Result<()> {
237        let thread = thread_id.0.clone();
238        let payload = serde_json::to_string(&message).context("failed to encode message")?;
239        self.with_conn(move |conn| {
240            conn.execute(
241                "INSERT INTO agent_messages (thread_id, payload) VALUES (?1, ?2)",
242                params![thread, payload],
243            )
244            .context("failed to append message")?;
245            Ok(())
246        })
247        .await
248    }
249
250    async fn get_history(&self, thread_id: &ThreadId) -> Result<Vec<llm::Message>> {
251        let thread = thread_id.0.clone();
252        self.with_conn(move |conn| {
253            let mut stmt = conn
254                .prepare("SELECT payload FROM agent_messages WHERE thread_id = ?1 ORDER BY id")
255                .context("failed to prepare message history query")?;
256            let rows = stmt
257                .query_map(params![thread], |row| row.get::<_, String>(0))
258                .context("failed to read message history")?;
259            let mut messages = Vec::new();
260            for row in rows {
261                let payload = row.context("failed to read message row")?;
262                messages.push(serde_json::from_str(&payload).context("failed to decode message")?);
263            }
264            Ok(messages)
265        })
266        .await
267    }
268
269    async fn clear(&self, thread_id: &ThreadId) -> Result<()> {
270        let thread = thread_id.0.clone();
271        self.with_conn(move |conn| {
272            conn.execute(
273                "DELETE FROM agent_messages WHERE thread_id = ?1",
274                params![thread],
275            )
276            .context("failed to clear messages")?;
277            Ok(())
278        })
279        .await
280    }
281
282    async fn count(&self, thread_id: &ThreadId) -> Result<usize> {
283        let thread = thread_id.0.clone();
284        self.with_conn(move |conn| {
285            let count: i64 = conn
286                .query_row(
287                    "SELECT COUNT(*) FROM agent_messages WHERE thread_id = ?1",
288                    params![thread],
289                    |row| row.get(0),
290                )
291                .context("failed to count messages")?;
292            usize::try_from(count).context("message count is negative")
293        })
294        .await
295    }
296
297    async fn replace_history(
298        &self,
299        thread_id: &ThreadId,
300        messages: Vec<llm::Message>,
301    ) -> Result<()> {
302        let thread = thread_id.0.clone();
303        let payloads = messages
304            .iter()
305            .map(|message| serde_json::to_string(message).context("failed to encode message"))
306            .collect::<Result<Vec<_>>>()?;
307        self.with_conn(move |conn| {
308            let tx = conn
309                .transaction()
310                .context("failed to begin replace_history transaction")?;
311            tx.execute(
312                "DELETE FROM agent_messages WHERE thread_id = ?1",
313                params![thread],
314            )
315            .context("failed to clear existing messages")?;
316            for payload in payloads {
317                tx.execute(
318                    "INSERT INTO agent_messages (thread_id, payload) VALUES (?1, ?2)",
319                    params![thread, payload],
320                )
321                .context("failed to insert replacement message")?;
322            }
323            tx.commit()
324                .context("failed to commit replace_history transaction")?;
325            Ok(())
326        })
327        .await
328    }
329}
330
331#[async_trait]
332impl StateStore for SqliteStore {
333    async fn save(&self, state: &AgentState) -> Result<()> {
334        let thread = state.thread_id.0.clone();
335        let payload = serde_json::to_string(state).context("failed to encode agent state")?;
336        self.with_conn(move |conn| {
337            conn.execute(
338                "INSERT INTO agent_states (thread_id, payload) VALUES (?1, ?2)
339                 ON CONFLICT(thread_id) DO UPDATE SET payload = excluded.payload",
340                params![thread, payload],
341            )
342            .context("failed to save agent state")?;
343            Ok(())
344        })
345        .await
346    }
347
348    async fn load(&self, thread_id: &ThreadId) -> Result<Option<AgentState>> {
349        let thread = thread_id.0.clone();
350        self.with_conn(move |conn| {
351            let payload: Option<String> = conn
352                .query_row(
353                    "SELECT payload FROM agent_states WHERE thread_id = ?1",
354                    params![thread],
355                    |row| row.get(0),
356                )
357                .optional()
358                .context("failed to load agent state")?;
359            match payload {
360                Some(payload) => Ok(Some(
361                    serde_json::from_str(&payload).context("failed to decode agent state")?,
362                )),
363                None => Ok(None),
364            }
365        })
366        .await
367    }
368
369    async fn delete(&self, thread_id: &ThreadId) -> Result<()> {
370        let thread = thread_id.0.clone();
371        self.with_conn(move |conn| {
372            conn.execute(
373                "DELETE FROM agent_states WHERE thread_id = ?1",
374                params![thread],
375            )
376            .context("failed to delete agent state")?;
377            Ok(())
378        })
379        .await
380    }
381}
382
383#[async_trait]
384impl EventStore for SqliteStore {
385    async fn append(
386        &self,
387        thread_id: &ThreadId,
388        turn: usize,
389        envelope: AgentEventEnvelope,
390    ) -> Result<()> {
391        let thread = thread_id.0.clone();
392        let turn_i64 = i64::try_from(turn).context("turn index exceeds i64 range")?;
393        let payload = serde_json::to_string(&envelope).context("failed to encode event")?;
394        self.with_conn(move |conn| {
395            // `BEGIN IMMEDIATE` takes the write lock up front so the
396            // finished-turn barrier check and the insert are one atomic unit:
397            // a concurrent connection cannot slip a `finish_turn` between them,
398            // and the whole append is a single WAL commit instead of three.
399            let tx = conn
400                .transaction_with_behavior(TransactionBehavior::Immediate)
401                .context("failed to begin append transaction")?;
402            let finished: Option<i64> = tx
403                .query_row(
404                    "SELECT finished FROM agent_event_turns WHERE thread_id = ?1 AND turn = ?2",
405                    params![thread, turn_i64],
406                    |row| row.get(0),
407                )
408                .optional()
409                .context("failed to read turn state")?;
410            anyhow::ensure!(finished != Some(1), "cannot append to finished turn {turn}");
411            tx.execute(
412                "INSERT OR IGNORE INTO agent_event_turns (thread_id, turn, finished)
413                 VALUES (?1, ?2, 0)",
414                params![thread, turn_i64],
415            )
416            .context("failed to record turn")?;
417            tx.execute(
418                "INSERT INTO agent_events (thread_id, turn, payload) VALUES (?1, ?2, ?3)",
419                params![thread, turn_i64, payload],
420            )
421            .context("failed to append event")?;
422            tx.commit().context("failed to commit append transaction")?;
423            Ok(())
424        })
425        .await
426    }
427
428    async fn finish_turn(&self, thread_id: &ThreadId, turn: usize) -> Result<()> {
429        let thread = thread_id.0.clone();
430        let turn_i64 = i64::try_from(turn).context("turn index exceeds i64 range")?;
431        self.with_conn(move |conn| {
432            // `BEGIN IMMEDIATE` makes the already-finished check and the
433            // finish write atomic against a concurrent connection, so the
434            // barrier cannot be double-finished or raced by an append.
435            let tx = conn
436                .transaction_with_behavior(TransactionBehavior::Immediate)
437                .context("failed to begin finish_turn transaction")?;
438            let finished: Option<i64> = tx
439                .query_row(
440                    "SELECT finished FROM agent_event_turns WHERE thread_id = ?1 AND turn = ?2",
441                    params![thread, turn_i64],
442                    |row| row.get(0),
443                )
444                .optional()
445                .context("failed to read turn state")?;
446            anyhow::ensure!(finished != Some(1), "turn {turn} is already finished");
447            tx.execute(
448                "INSERT INTO agent_event_turns (thread_id, turn, finished) VALUES (?1, ?2, 1)
449                 ON CONFLICT(thread_id, turn) DO UPDATE SET finished = 1",
450                params![thread, turn_i64],
451            )
452            .context("failed to finish turn")?;
453            tx.commit()
454                .context("failed to commit finish_turn transaction")?;
455            Ok(())
456        })
457        .await
458    }
459
460    async fn get_turn(
461        &self,
462        thread_id: &ThreadId,
463        turn: usize,
464    ) -> Result<Option<StoredTurnEvents>> {
465        let thread = thread_id.0.clone();
466        let turn_i64 = i64::try_from(turn).context("turn index exceeds i64 range")?;
467        self.with_conn(move |conn| {
468            // Deferred read transaction so the turn row and its events come
469            // from one WAL snapshot: without it another process could finish
470            // the turn (or clear the thread) between the two queries,
471            // yielding a torn `finished`/events view.
472            let tx = conn
473                .transaction()
474                .context("failed to begin turn read transaction")?;
475            let finished: Option<i64> = tx
476                .query_row(
477                    "SELECT finished FROM agent_event_turns WHERE thread_id = ?1 AND turn = ?2",
478                    params![thread, turn_i64],
479                    |row| row.get(0),
480                )
481                .optional()
482                .context("failed to read turn state")?;
483            let Some(finished) = finished else {
484                return Ok(None);
485            };
486            let events = {
487                let mut stmt = tx
488                    .prepare(
489                        "SELECT payload FROM agent_events
490                         WHERE thread_id = ?1 AND turn = ?2 ORDER BY id",
491                    )
492                    .context("failed to prepare turn events query")?;
493                let rows = stmt
494                    .query_map(params![thread, turn_i64], |row| row.get::<_, String>(0))
495                    .context("failed to read turn events")?;
496                let mut events = Vec::new();
497                for row in rows {
498                    let payload = row.context("failed to read event row")?;
499                    events.push(serde_json::from_str(&payload).context("failed to decode event")?);
500                }
501                events
502            };
503            tx.commit().context("failed to end turn read transaction")?;
504            Ok(Some(StoredTurnEvents {
505                turn,
506                events,
507                finished: finished != 0,
508            }))
509        })
510        .await
511    }
512
513    async fn get_turns(&self, thread_id: &ThreadId) -> Result<Vec<StoredTurnEvents>> {
514        let thread = thread_id.0.clone();
515        self.with_conn(move |conn| {
516            // Deferred read transaction: both statements must observe one WAL
517            // snapshot, or a turn appended (or a clear run) by another
518            // process between them produces a torn view — events silently
519            // dropped for unknown turns, or turn entries with emptied lists.
520            let tx = conn
521                .transaction()
522                .context("failed to begin turns read transaction")?;
523            let mut turns: Vec<StoredTurnEvents> = Vec::new();
524            {
525                let mut turn_stmt = tx
526                    .prepare(
527                        "SELECT turn, finished FROM agent_event_turns
528                         WHERE thread_id = ?1 ORDER BY turn",
529                    )
530                    .context("failed to prepare turns query")?;
531                let turn_rows = turn_stmt
532                    .query_map(params![thread], |row| {
533                        Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
534                    })
535                    .context("failed to read turns")?;
536                let mut position = std::collections::HashMap::new();
537                for row in turn_rows {
538                    let (turn_i64, finished) = row.context("failed to read turn row")?;
539                    let turn = usize::try_from(turn_i64).context("turn index is negative")?;
540                    position.insert(turn_i64, turns.len());
541                    turns.push(StoredTurnEvents {
542                        turn,
543                        events: Vec::new(),
544                        finished: finished != 0,
545                    });
546                }
547                let mut event_stmt = tx
548                    .prepare(
549                        "SELECT turn, payload FROM agent_events
550                         WHERE thread_id = ?1 ORDER BY turn, id",
551                    )
552                    .context("failed to prepare events query")?;
553                let event_rows = event_stmt
554                    .query_map(params![thread], |row| {
555                        Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
556                    })
557                    .context("failed to read events")?;
558                for row in event_rows {
559                    let (turn_i64, payload) = row.context("failed to read event row")?;
560                    let envelope =
561                        serde_json::from_str(&payload).context("failed to decode event")?;
562                    if let Some(&index) = position.get(&turn_i64) {
563                        turns[index].events.push(envelope);
564                    }
565                }
566            }
567            tx.commit()
568                .context("failed to end turns read transaction")?;
569            Ok(turns)
570        })
571        .await
572    }
573
574    async fn get_events(&self, thread_id: &ThreadId) -> Result<Vec<AgentEventEnvelope>> {
575        let thread = thread_id.0.clone();
576        self.with_conn(move |conn| {
577            let mut stmt = conn
578                .prepare("SELECT payload FROM agent_events WHERE thread_id = ?1 ORDER BY turn, id")
579                .context("failed to prepare events query")?;
580            let rows = stmt
581                .query_map(params![thread], |row| row.get::<_, String>(0))
582                .context("failed to read events")?;
583            let mut events = Vec::new();
584            for row in rows {
585                let payload = row.context("failed to read event row")?;
586                events.push(serde_json::from_str(&payload).context("failed to decode event")?);
587            }
588            Ok(events)
589        })
590        .await
591    }
592
593    async fn event_count(&self, thread_id: &ThreadId) -> Result<usize> {
594        let thread = thread_id.0.clone();
595        self.with_conn(move |conn| {
596            let count: i64 = conn
597                .query_row(
598                    "SELECT COUNT(*) FROM agent_events WHERE thread_id = ?1",
599                    params![thread],
600                    |row| row.get(0),
601                )
602                .context("failed to count events")?;
603            usize::try_from(count).context("event count is negative")
604        })
605        .await
606    }
607
608    async fn get_events_since(
609        &self,
610        thread_id: &ThreadId,
611        offset: usize,
612    ) -> Result<Vec<AgentEventEnvelope>> {
613        let thread = thread_id.0.clone();
614        let offset_i64 = i64::try_from(offset).context("offset exceeds i64 range")?;
615        self.with_conn(move |conn| {
616            let mut stmt = conn
617                .prepare(
618                    "SELECT payload FROM agent_events WHERE thread_id = ?1
619                     ORDER BY turn, id LIMIT -1 OFFSET ?2",
620                )
621                .context("failed to prepare events query")?;
622            let rows = stmt
623                .query_map(params![thread, offset_i64], |row| row.get::<_, String>(0))
624                .context("failed to read events")?;
625            let mut events = Vec::new();
626            for row in rows {
627                let payload = row.context("failed to read event row")?;
628                events.push(serde_json::from_str(&payload).context("failed to decode event")?);
629            }
630            Ok(events)
631        })
632        .await
633    }
634
635    async fn clear(&self, thread_id: &ThreadId) -> Result<()> {
636        let thread = thread_id.0.clone();
637        self.with_conn(move |conn| {
638            let tx = conn
639                .transaction()
640                .context("failed to begin event clear transaction")?;
641            tx.execute(
642                "DELETE FROM agent_events WHERE thread_id = ?1",
643                params![thread],
644            )
645            .context("failed to clear events")?;
646            tx.execute(
647                "DELETE FROM agent_event_turns WHERE thread_id = ?1",
648                params![thread],
649            )
650            .context("failed to clear turns")?;
651            tx.commit()
652                .context("failed to commit event clear transaction")?;
653            Ok(())
654        })
655        .await
656    }
657}
658
659#[async_trait]
660impl ToolExecutionStore for SqliteStore {
661    async fn get_execution(&self, tool_call_id: &str) -> Result<Option<ToolExecution>> {
662        let id = tool_call_id.to_owned();
663        self.with_conn(move |conn| {
664            let payload: Option<String> = conn
665                .query_row(
666                    "SELECT payload FROM agent_tool_executions WHERE tool_call_id = ?1",
667                    params![id],
668                    |row| row.get(0),
669                )
670                .optional()
671                .context("failed to read tool execution")?;
672            match payload {
673                Some(payload) => Ok(Some(
674                    serde_json::from_str(&payload).context("failed to decode tool execution")?,
675                )),
676                None => Ok(None),
677            }
678        })
679        .await
680    }
681
682    async fn record_execution(&self, execution: ToolExecution) -> Result<()> {
683        self.upsert_execution(execution).await
684    }
685
686    async fn update_execution(&self, execution: ToolExecution) -> Result<()> {
687        self.upsert_execution(execution).await
688    }
689
690    async fn get_execution_by_operation_id(
691        &self,
692        operation_id: &str,
693    ) -> Result<Option<ToolExecution>> {
694        let id = operation_id.to_owned();
695        self.with_conn(move |conn| {
696            // Two rows can share an `operation_id`; pick the most recently
697            // *inserted* one (upserts keep their original rowid, so an
698            // update to an older row does not promote it — unlike
699            // `InMemoryExecutionStore`, whose index tracks the last writer).
700            // Colliding operation ids are degenerate and no caller depends
701            // on the tiebreak; a deterministic order is what matters here.
702            let payload: Option<String> = conn
703                .query_row(
704                    "SELECT payload FROM agent_tool_executions WHERE operation_id = ?1
705                     ORDER BY rowid DESC LIMIT 1",
706                    params![id],
707                    |row| row.get(0),
708                )
709                .optional()
710                .context("failed to read tool execution by operation id")?;
711            match payload {
712                Some(payload) => Ok(Some(
713                    serde_json::from_str(&payload).context("failed to decode tool execution")?,
714                )),
715                None => Ok(None),
716            }
717        })
718        .await
719    }
720}
721
722#[cfg(test)]
723mod tests {
724    use super::*;
725    use agent_sdk_foundation::events::{AgentEvent, AgentEventEnvelope, SequenceCounter};
726    use agent_sdk_foundation::llm::Message;
727    use agent_sdk_foundation::types::ToolResult;
728    use anyhow::bail;
729    use tempfile::tempdir;
730
731    /// Open, write across all four stores, drop, reopen the same file, and
732    /// assert every record survived — the resume contract.
733    #[tokio::test]
734    async fn persists_all_stores_across_reopen() -> Result<()> {
735        let dir = tempdir()?;
736        let path = dir.path().join("agent.db");
737        let thread_id = ThreadId::new();
738        let seq = SequenceCounter::new();
739
740        // Session 1: write through every trait, then drop the store.
741        {
742            let store = SqliteStore::open(&path)?;
743
744            MessageStore::append(&store, &thread_id, Message::user("hello")).await?;
745            MessageStore::append(&store, &thread_id, Message::assistant("hi there")).await?;
746
747            let state = AgentState::new(thread_id.clone());
748            store.save(&state).await?;
749
750            EventStore::append(
751                &store,
752                &thread_id,
753                1,
754                AgentEventEnvelope::wrap(AgentEvent::text("m1", "streamed"), &seq),
755            )
756            .await?;
757            store.finish_turn(&thread_id, 1).await?;
758
759            let execution = ToolExecution::new_in_flight(
760                "call_1",
761                thread_id.clone(),
762                "my_tool",
763                "My Tool",
764                serde_json::json!({"k": "v"}),
765                time::OffsetDateTime::now_utc(),
766            );
767            store.record_execution(execution).await?;
768        }
769
770        // Session 2: reopen the same file and confirm durability.
771        let store = SqliteStore::open(&path)?;
772
773        let history = store.get_history(&thread_id).await?;
774        assert_eq!(history.len(), 2, "messages must survive reopen");
775        assert_eq!(MessageStore::count(&store, &thread_id).await?, 2);
776
777        let loaded = store.load(&thread_id).await?;
778        assert!(loaded.is_some(), "state must survive reopen");
779        assert_eq!(
780            loaded.context("state present")?.thread_id,
781            thread_id,
782            "loaded state must match the thread"
783        );
784
785        let events = store.get_events(&thread_id).await?;
786        assert_eq!(events.len(), 1, "events must survive reopen");
787        assert_eq!(store.event_count(&thread_id).await?, 1);
788        let turn = store
789            .get_turn(&thread_id, 1)
790            .await?
791            .context("turn 1 present")?;
792        assert!(turn.finished, "finish barrier must survive reopen");
793
794        let execution = store
795            .get_execution("call_1")
796            .await?
797            .context("execution present")?;
798        assert_eq!(execution.tool_name, "my_tool");
799        assert!(execution.is_in_flight());
800
801        Ok(())
802    }
803
804    /// A second `ThreadId` against the same file is an independent conversation.
805    #[tokio::test]
806    async fn threads_are_isolated_in_one_file() -> Result<()> {
807        let dir = tempdir()?;
808        let path = dir.path().join("agent.db");
809        let store = SqliteStore::open(&path)?;
810
811        let thread_a = ThreadId::new();
812        let thread_b = ThreadId::new();
813
814        MessageStore::append(&store, &thread_a, Message::user("a-only")).await?;
815
816        assert_eq!(store.get_history(&thread_a).await?.len(), 1);
817        assert!(
818            store.get_history(&thread_b).await?.is_empty(),
819            "a fresh thread starts empty in a shared file"
820        );
821        Ok(())
822    }
823
824    /// The finish barrier is durable: appending to a reopened, finished turn
825    /// must still be rejected.
826    #[tokio::test]
827    async fn rejects_append_after_finish_across_reopen() -> Result<()> {
828        let dir = tempdir()?;
829        let path = dir.path().join("agent.db");
830        let thread_id = ThreadId::new();
831        let seq = SequenceCounter::new();
832
833        {
834            let store = SqliteStore::open(&path)?;
835            store.finish_turn(&thread_id, 1).await?;
836        }
837
838        let store = SqliteStore::open(&path)?;
839        let Err(error) = EventStore::append(
840            &store,
841            &thread_id,
842            1,
843            AgentEventEnvelope::wrap(AgentEvent::text("late", "late"), &seq),
844        )
845        .await
846        else {
847            bail!("append after finish must fail");
848        };
849        assert!(error.to_string().contains("cannot append to finished turn"));
850        Ok(())
851    }
852
853    /// A database stamped by a newer SDK must be rejected on open, not have
854    /// v1 DDL applied over a format this binary does not understand. A v1
855    /// stamp reopens fine.
856    #[tokio::test]
857    async fn rejects_newer_schema_version() -> Result<()> {
858        let dir = tempdir()?;
859        let path = dir.path().join("agent.db");
860
861        {
862            let _store = SqliteStore::open(&path)?;
863        }
864        // Simulate a future SDK bumping the format version.
865        {
866            let conn = Connection::open(&path)?;
867            conn.pragma_update(None, "user_version", SCHEMA_VERSION + 1)?;
868        }
869
870        let Err(error) = SqliteStore::open(&path) else {
871            bail!("opening a newer-versioned database must fail");
872        };
873        assert!(
874            error.to_string().contains("newer than the"),
875            "unexpected error: {error}"
876        );
877        Ok(())
878    }
879
880    /// The finish barrier rejects an append to a finished turn within a single
881    /// process too (not only across reopen), and an append to an unfinished
882    /// turn still succeeds afterward.
883    #[tokio::test]
884    async fn rejects_append_after_finish_in_process() -> Result<()> {
885        let dir = tempdir()?;
886        let path = dir.path().join("agent.db");
887        let thread_id = ThreadId::new();
888        let seq = SequenceCounter::new();
889
890        let store = SqliteStore::open(&path)?;
891        EventStore::append(
892            &store,
893            &thread_id,
894            1,
895            AgentEventEnvelope::wrap(AgentEvent::text("m1", "early"), &seq),
896        )
897        .await?;
898        store.finish_turn(&thread_id, 1).await?;
899
900        let Err(error) = EventStore::append(
901            &store,
902            &thread_id,
903            1,
904            AgentEventEnvelope::wrap(AgentEvent::text("late", "late"), &seq),
905        )
906        .await
907        else {
908            bail!("append after finish must fail");
909        };
910        assert!(error.to_string().contains("cannot append to finished turn"));
911
912        // A different, unfinished turn still accepts appends.
913        EventStore::append(
914            &store,
915            &thread_id,
916            2,
917            AgentEventEnvelope::wrap(AgentEvent::text("m2", "ok"), &seq),
918        )
919        .await?;
920        assert_eq!(store.event_count(&thread_id).await?, 2);
921        Ok(())
922    }
923
924    /// Two executions sharing one `operation_id` must resolve to the most
925    /// recently written one, matching `InMemoryExecutionStore`'s latest-wins
926    /// `HashMap` contract instead of returning an indeterminate row.
927    #[tokio::test]
928    async fn operation_id_lookup_is_latest_wins() -> Result<()> {
929        let dir = tempdir()?;
930        let path = dir.path().join("agent.db");
931        let store = SqliteStore::open(&path)?;
932        let thread_id = ThreadId::new();
933
934        let mut first = ToolExecution::new_in_flight(
935            "call_first",
936            thread_id.clone(),
937            "tool",
938            "Tool",
939            serde_json::json!({}),
940            time::OffsetDateTime::now_utc(),
941        );
942        first.set_operation_id("op_shared");
943        store.record_execution(first).await?;
944
945        let mut second = ToolExecution::new_in_flight(
946            "call_second",
947            thread_id,
948            "tool",
949            "Tool",
950            serde_json::json!({}),
951            time::OffsetDateTime::now_utc(),
952        );
953        second.set_operation_id("op_shared");
954        store.record_execution(second).await?;
955
956        assert_eq!(
957            store
958                .get_execution_by_operation_id("op_shared")
959                .await?
960                .context("op_shared resolves")?
961                .tool_call_id,
962            "call_second",
963            "the most recently written execution must win"
964        );
965        Ok(())
966    }
967
968    /// Re-pointing an execution's `operation_id` stops the old id resolving.
969    #[tokio::test]
970    async fn operation_id_lookup_and_supersession() -> Result<()> {
971        let dir = tempdir()?;
972        let path = dir.path().join("agent.db");
973        let store = SqliteStore::open(&path)?;
974        let thread_id = ThreadId::new();
975
976        let mut execution = ToolExecution::new_in_flight(
977            "call_op",
978            thread_id,
979            "async_tool",
980            "Async Tool",
981            serde_json::json!({}),
982            time::OffsetDateTime::now_utc(),
983        );
984        execution.set_operation_id("op_old");
985        store.record_execution(execution.clone()).await?;
986        assert_eq!(
987            store
988                .get_execution_by_operation_id("op_old")
989                .await?
990                .context("op_old resolves")?
991                .tool_call_id,
992            "call_op"
993        );
994
995        execution.set_operation_id("op_new");
996        store.update_execution(execution).await?;
997        assert!(
998            store
999                .get_execution_by_operation_id("op_old")
1000                .await?
1001                .is_none(),
1002            "superseded operation id must stop resolving"
1003        );
1004        assert_eq!(
1005            store
1006                .get_execution_by_operation_id("op_new")
1007                .await?
1008                .context("op_new resolves")?
1009                .tool_call_id,
1010            "call_op"
1011        );
1012
1013        // Completing the execution survives and is observable.
1014        let mut completed = store
1015            .get_execution("call_op")
1016            .await?
1017            .context("execution present")?;
1018        completed.complete(ToolResult::success("done"));
1019        store.update_execution(completed).await?;
1020        let reloaded = store
1021            .get_execution("call_op")
1022            .await?
1023            .context("execution present")?;
1024        assert!(reloaded.is_completed());
1025        Ok(())
1026    }
1027}