Guides

Guide: Persistent sessions

Move an agent from in-memory storage to SQLite-backed sessions, scoped long-lived state, filesystem artifacts, and recallable memory.

The in-memory services that make quickstarts pleasant make production agents amnesiac: one process restart and every conversation, fact, and file is gone. This guide swaps each volatile service for a durable one — SQLite sessions, user-scoped state, filesystem artifacts, and long-term memory — one step at a time.

1. Why in-memory loses everything

A session is an append-only event log plus a state map, owned by whatever SessionService you hand the Runner. InMemorySessionService keeps both in process memory — perfect for tests, fatal for anything users return to. The fix is purely configurational: the SessionService trait is the same, so your agent code does not change at all.

2. Enable sqlite and connect

Cargo.tomltoml
[dependencies]
adk-rs = { version = "0.6", features = ["gemini", "sqlite", "fs"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
futures = "0.3"
serde_json = "1"

SqliteSessionService::connect takes a sqlx-style URL, opens a small connection pool, and runs the bundled schema migration automatically — there is no separate setup step. The schema is idempotent (CREATE TABLE IF NOT EXISTS ...), so reconnecting to an existing database is safe. (A postgres feature exports PostgresSessionService with the same API.)

rustrust
use adk_rs::core::SessionService;
use adk_rs::runner::Runner;
use adk_rs::services::sql::SqliteSessionService;
use std::sync::Arc;

// "mode=rwc" = read/write/create: sqlx creates the file if it is missing.
// In tests, "sqlite::memory:" gives you a throwaway database.
let sessions: Arc<dyn SessionService> =
    Arc::new(SqliteSessionService::connect("sqlite://adk.db?mode=rwc").await?);

let runner = Runner::builder()
    .app_name("assistant")
    .agent(agent)
    .session_service(sessions.clone())
    .auto_create_session(true) // create sessions under ids *you* choose
    .build()?;

3. Reuse session ids across restarts

Continuing a conversation needs no special API. Sessions are keyed by (app_name, user_id, session_id); pass the same id on every turn and the runner loads the stored event log, so the model sees the full prior history — even if the process restarted in between. With auto_create_session(true), an unknown id is created rather than rejected.

rustrust
// The id is just a string — derive it from your own conversation model.
let session_id = "alice-main";

// Run 1 (today):
let mut events = runner.run("alice", Some(session_id), "My dog is called Bruno.").await?;
while let Some(ev) = events.next().await { ev?; }

// ... process exits, redeploys, restarts ...

// Run 2 (tomorrow, fresh process): same id, full history.
let mut events = runner.run("alice", Some(session_id), "What is my dog called?").await?;
while let Some(ev) = events.next().await {
    if let Some(c) = ev?.response.content {
        println!("{}", c.text_concat()); // "Bruno."
    }
}

4. Long-lived facts: the user: and app: scopes

Session-scoped state dies with the session’s relevance. For facts that should outlive one conversation, prefix the key — the SQLite backend routes each scope to its own table and overlays them on read, so user: keys are visible from every session of that user:

PrefixVisible toStored in (SQLite)
app:every user and session of the appapp_state table
user:every session of one useruser_state table
(none)this session onlysessions.state column
temp:the current invocationnever persisted

Seed scoped state when creating a session, or let an agent write it by giving output_key a prefixed name — the session service partitions each event’s state_delta by scope when persisting:

rustrust
use adk_rs::core::State;
use serde_json::json;

// Seed at creation time:
let mut state = State::new();
state.set("user:name", json!("Alice"));
state.set("app:brand_voice", json!("friendly, concise"));
sessions
    .create_session("assistant", "alice", Some(state), Some("alice-main"))
    .await?;

// Or let an agent maintain the fact itself:
let profiler = LlmAgent::builder("profiler")
    .model(model.clone())
    .instruction("Summarize everything learned about this user in one short paragraph.")
    .output_key("user:profile") // lands in user_state, shared across sessions
    .build()?;

5. Add the filesystem artifact service

Binary or large outputs belong in artifacts, not state. The fs feature provides FileArtifactService, which stores each artifact as versioned JSON files under <root>/<app>/<user>/<session>/<filename>/v000001.json (path components are sanitized, so hostile names cannot escape the root). Tools save and load artifacts through their ToolContext; agents can pull artifact text into an instruction with {artifact.<filename>} templating or list files via adk_rs::tools::load_artifacts_tool().

rustrust
use adk_rs::services::fs::FileArtifactService;

let runner = Runner::builder()
    .app_name("assistant")
    .agent(agent)
    .session_service(sessions.clone())
    .artifact_service(Arc::new(FileArtifactService::new("./artifacts")))
    .auto_create_session(true)
    .build()?;

6. Optional: ingest sessions into memory

Sessions answer “what happened in this conversation”; memory answers “what do we know across conversations”. When a conversation wraps up, fetch it and index it with add_session_to_memory. To recall, register adk_rs::tools::load_memory_tool() on the agent and set .memory_service(memory.clone()) on the runner — the model then calls load_memory with a query whenever it needs facts from past conversations.

rustrust
use adk_rs::core::{GetSessionConfig, MemoryService};
use adk_rs::services::mem::InMemoryMemoryService;

let memory = Arc::new(InMemoryMemoryService::new());

// When a conversation is finished, fold it into long-term memory:
if let Some(finished) = sessions
    .get_session("assistant", "alice", "alice-main", GetSessionConfig::default())
    .await?
{
    memory.add_session_to_memory(&finished).await?;
}

Where next