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-backed delegation​

The optional PostgreSQL backend also stores durable async-delegation records when the same store is explicitly injected into AIAgent(session_db=...). Delegation completion and session_search(db=...) then use the PostgreSQL SessionDB; no SQLite sidecar is created. The default delegation path remains SQLite when no PostgreSQL store is injected, and memory/plugin stores are not implicitly routed to PostgreSQL.

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+psycopg:// DSN; do not rely on an implicit DATABASE_URL lookup inside the library:

from hermes_state_postgres import SessionDB

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

The backend uses psycopg 3's native asyncio connection. Psycopg does not support Windows' default Proactor event loop; Windows hosts must select a compatible selector loop. No thread-based compatibility fallback is provided.

Pool and psycopg 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:
connect_timeout: 60
prepare_threshold: 5
application_name: async-hermes-agent
options: >-
-c statement_timeout=60000
-c lock_timeout=5000
-c 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.

These are the actual psycopg/libpq option names. connect_timeout controls connection establishment and prepare_threshold: null disables prepared statements. Server-side statement and lock timeouts belong in the libpq options string. The former asyncpg-only keys (timeout, command_timeout, server_settings, and asyncpg statement-cache settings) are rejected rather than silently translated.

On a writable store, first initialization creates a fresh schema or applies a known PostgreSQL physical migration under a PostgreSQL transaction advisory lock. The logical schema version follows upstream; PostgreSQL-only index and constraint layout is tracked in the existing state_meta table. The migration uses ordinary transactional DDL so data, indexes, constraints, and version metadata commit or roll back together. It intentionally does not use CREATE INDEX CONCURRENTLY: that operation cannot be included in the same transaction and may leave an invalid index after failure. Writes may therefore wait while a production preflight runs, while reads remain available.

The migration is fail-closed for newer, missing, malformed, or ambiguous schemas. It does not run Alembic, destructive rewrites, or arbitrary missing-column repair. A read-only store requires both the current logical schema and current PostgreSQL physical layout and never creates or migrates schema. For production, take a backup/PITR point, drain writer workers, run a single preflight against the direct PostgreSQL endpoint, verify the catalog, and only then start new workers. A future upstream SQLite schema change must be ported as an explicit PostgreSQL migration 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.