Custom Persistence Guide

Copy Markdown View Source

This guide explains how to implement custom persistence adapters for AgentSessionManager.

Core ports:

  • SessionStore for session/run/event persistence
  • ArtifactStore for binary artifact storage
  • QueryAPI for cross-session read/query operations
  • Maintenance for retention and integrity workflows

SessionStore

AgentSessionManager.Ports.SessionStore is the primary persistence contract.

Callback groups:

  • Session callbacks: save_session/2, get_session/2, list_sessions/2, delete_session/2
  • Run callbacks: save_run/2, get_run/2, list_runs/3, get_active_run/2
  • Event callbacks: append_event/2, append_event_with_sequence/2, append_events/2, get_events/3, get_latest_sequence/2
  • Atomic lifecycle callback: flush/2

The SessionStore port accepts both:

  • GenServer-backed refs (pid/name)
  • module-backed refs ({Module, context})

Semantics your adapter should guarantee

  • Idempotent writes for duplicate IDs
  • Append-only event behavior
  • Atomic, monotonic per-session sequence assignment
  • Consistent error tuples using AgentSessionManager.Core.Error

ArtifactStore

AgentSessionManager.Ports.ArtifactStore is a simple binary contract:

  • put/4
  • get/3
  • delete/3

Keys should be treated as immutable identifiers from the application boundary.

QueryAPI and Maintenance

For SQL-backed stacks, query and maintenance are separate module adapters. They accept an explicit context (usually Repo) via tuple refs at call sites:

query_ref = {MyApp.QueryAdapter, query_context}
maint_ref = {MyApp.MaintenanceAdapter, maintenance_context}

This keeps read/reporting and maintenance operations independent from the SessionStore process lifecycle.

For cursor pagination in custom QueryAPI adapters:

  • treat cursors as opaque tokens
  • encode enough state to preserve ordering (order_by + tie-break fields)
  • return {:error, %Error{code: :invalid_cursor}} for malformed or mismatched cursors

Most custom stores use GenServer for write serialization and simple lifecycle management.

defmodule MyApp.CustomSessionStore do
  use GenServer

  @behaviour AgentSessionManager.Ports.SessionStore

  alias AgentSessionManager.Core.{Error, Event, Run, Session}
  alias AgentSessionManager.Ports.SessionStore

  def start_link(opts), do: GenServer.start_link(__MODULE__, opts)

  @impl SessionStore
  def save_session(store, %Session{} = session),
    do: GenServer.call(store, {:save_session, session})

  @impl SessionStore
  def append_events(store, events),
    do: GenServer.call(store, {:append_events, events})

  @impl SessionStore
  def flush(store, execution_result),
    do: GenServer.call(store, {:flush, execution_result})

  # Implement remaining callbacks similarly...
end

Atomic sequencing and flush

append_event_with_sequence/2 and append_events/2

Use one atomic unit (transaction or lock) to:

  1. Read/update per-session sequence counter
  2. Insert event rows
  3. Return persisted events with assigned sequence_number

flush/2

flush/2 should atomically persist:

  • session
  • run
  • events
  • provider metadata (if your backend stores it)

If any part fails, no partial write should remain in persistent state.

Error handling

Return AgentSessionManager.Core.Error for all failures.

alias AgentSessionManager.Core.Error

{:error, Error.new(:session_not_found, "Session not found: #{session_id}")}
{:error, Error.new(:run_not_found, "Run not found: #{run_id}")}
{:error, Error.new(:storage_error, "Write failed: #{inspect(reason)}")}

Testing strategy

Run contract-style tests against your adapter and include concurrency checks for:

  • duplicate event IDs
  • concurrent appends
  • sequence monotonicity
  • flush/2 rollback behavior

When possible, verify behavior through SessionStore port calls instead of adapter-internal functions.