Skip to main content

Session Storage

SessionDB in hermes_state.py stores conversations in SQLite. Its public I/O methods are coroutines backed by aiosqlite, so callers do not need to add their own thread wrapper. aiosqlite itself serializes SQLite calls on a connection worker thread; this is an awaitable facade, not a zero-thread native SQLite transport.

The default database is:

$HERMES_HOME/state.db

HERMES_HOME defaults to ~/.hermes. Pass an explicit path when an embedding application should own storage placement.

Direct use

from hermes_state import SessionDB


db = SessionDB("/srv/my-agent/state.db")


async def inspect_session():
try:
await db.create_session(
"conversation-1",
source="api",
model="example-model",
)
await db.append_message("conversation-1", "user", "Hello")
await db.append_message("conversation-1", "assistant", "Hi")

session = await db.get_session("conversation-1")
messages = await db.get_messages("conversation-1")
return session, messages
finally:
await db.close()

Construction records the path only. The connection and schema are initialized lazily on the first awaited operation.

Using storage with AIAgent

AIAgent receives optional storage through the existing session_db= constructor argument. Without an injected SessionDB, an ordinary turn does not persist a transcript. A recall tool that requires session storage may instead open the default $HERMES_HOME/state.db lazily; from that point the agent owns the handle. With a store attached at construction, a turn creates or enriches the session row before its first provider request and incrementally persists messages during the tool loop.

An injected database is borrowed by the agent: await agent.close() ends the agent's session work but does not close the attached SessionDB. The host that created the store owns its lifecycle and should close it once during application shutdown. In a service, create one store per worker lifespan and share that store among the worker's agents; do not let an individual agent close the shared store.

Stored data

The schema preserves the upstream session model, including:

  • session identity, source, provider/model metadata, timestamps, and working directory information;
  • message roles, content, tool calls, tool-call identifiers, and tool names;
  • reasoning and provider-specific replay metadata;
  • token and auxiliary-model usage accounting;
  • compression lineage, titles, archive state, and other session metadata;
  • FTS indexes used by session and message search.

The system prompt is de-duplicated by hash. This supports stable prompt reuse without storing an identical large prompt on every session row.

Core operations

All operations below are awaited:

OperationRepresentative methods
Create/resumecreate_session(), ensure_session(), reopen_session()
Append/replaceappend_message(), append_messages_batch(), replace_messages()
Readget_session(), get_messages(), get_messages_as_conversation()
Searchsearch_messages(), search_sessions(), search_sessions_by_id()
Compressiontry_acquire_compression_lock(), archive_and_compact(), release_compression_lock()
Metadataupdate_session_meta(), update_session_model(), update_token_counts()
Lifecycleend_session(), delete_session(), close()

Consult the method signatures in hermes_state.py for optional filters and return fields; that file is the canonical API reference.

Write serialization and WAL

One SessionDB instance lazily owns an asyncio.Lock for connection setup and another for writes. SQLite WAL is enabled when the filesystem supports it, with the existing journal fallback retained for filesystems where WAL is not safe or available. SQLite's busy handling and bounded retry policy remain in the database layer.

Separate SessionDB instances can operate concurrently, subject to SQLite's normal file-level locking. A single instance still serializes mutations so transcript order remains deterministic.

search_messages() uses the retained FTS5 routing and falls back according to the database's available extensions and query shape. Search and index repair remain awaited operations. Optional CJK indexing depends on the cjk_unicode61 loadable SQLite extension being built and available for the current platform; when it is absent, the retained search fallback remains in use.

async def find_messages(db):
return await db.search_messages(
"deployment failure",
role_filter=["user", "assistant"],
limit=10,
)

session_search exposes this storage to the model when its toolset is enabled.

PostgreSQL settings and read-only connections

See the PostgreSQL production-readiness report for the reproducible multi-worker, pool-recovery, and failure tests.

The optional PostgreSQL backend keeps the SessionDB(db_path, read_only=False) constructor shape. Pass an explicit postgresql+asyncpg:// DSN; do not rely on an implicit DATABASE_URL lookup inside the library:

from hermes_state_postgres import SessionDB

db = SessionDB("postgresql+asyncpg://user:password@db.example/hermes")

Pool and asyncpg options belong under the active profile's config.yaml and use the driver names directly:

database:
postgres:
pool_size: 5
max_overflow: 10
pool_timeout: 30
pool_recycle: -1
pool_pre_ping: true
pool_use_lifo: false
connect_args:
timeout: 60
command_timeout: null
statement_cache_size: 100
max_cached_statement_lifetime: 300
max_cacheable_statement_size: 15360
server_settings:
application_name: async-hermes-agent
statement_timeout: "60000"
lock_timeout: "5000"
idle_in_transaction_session_timeout: "600000"

The profile selected when the store is constructed owns these settings. The async connection is still initialized at the first awaited operation, and the validated options remain fixed for that store's lifetime. Changing the config requires a newly created store. TLS and endpoint selection remain DSN concerns.

On a writable store, first initialization creates or additively reconciles the retained tables, foreign keys, and query indexes under a PostgreSQL advisory transaction lock, then records the retained schema version. It does not run Alembic or perform destructive rewrites. A read-only store only validates the existing version and never creates or migrates schema. When a future upstream release changes the SQLite schema, the corresponding PostgreSQL column/data migration must be ported and tested before that release is advertised for existing PostgreSQL databases.

For a read replica or a search/diagnostic connection, use:

readonly_db = SessionDB(read_replica_url, read_only=True)

This refuses SessionDB writes and forces PostgreSQL transactions into read-only mode. It does not select a replica automatically, and it requires an already initialized schema. A separate PostgreSQL read-only role or replica endpoint provides an additional operational permission boundary. When sharing one store in a service worker, estimate the possible connection count as workers * (pool_size + max_overflow).

Crash and cancellation behavior

The turn prologue persists the user message before the first provider request. Tool-loop progress is persisted incrementally, and finalization closes or repairs incomplete protocol tails before its final write. On host task cancellation, the agent shields the short finalization needed to leave durable state consistent, then propagates cancellation.

JSONL trajectories are separate from state.db; see Trajectory Format.

Shutdown

Always await close(). It is safe to call once in a finally block:

async def list_recent_sessions():
db = SessionDB()
try:
return await db.search_sessions(limit=20)
finally:
await db.close()

Reusing a closed SessionDB raises an error rather than silently opening a new connection.