Session Mirror
Mirror session transcripts to a remote backend without blocking the local turn
A mirror replicates each persisted turn to a remote backend on a background thread, so a session can follow the user across machines — local disk stays the source of truth.
Quick Start
- Plug a mirror into DefaultSessionStore
python from typing import Any, Dict, List from praisonaiagents import Agent from praisonaiagents.session import DefaultSessionStore, SessionMirrorProtocol class LoggingMirror: """Minimal SessionMirrorProtocol implementation — keeps records in memory.""" def __init__(self): self._store: Dict[str, List[Dict[str, Any]]] = {} def append(self, session_id: str, records: List[Dict[str, Any]]) -> None: self._store.setdefault(session_id, []).extend(records) print(f"[mirror] +{len(records)} record(s) for {session_id}") def load(self, session_id: str) -> List[Dict[str, Any]]: return list(self._store.get(session_id, [])) store = DefaultSessionStore(mirror=LoggingMirror()) agent = Agent( name="Assistant", instructions="Be concise.", memory={"session_id": "user-42", "session_store": store}, ) agent.start("Remember I like green tea.")The local file at
~/.praisonai/sessions/user-42.jsonand the mirror both receive the turn. - Continue on another machine
python store = DefaultSessionStore(mirror=LoggingMirror()) records = store._mirror.load("user-42") # hydrate a session never seen locallyA configured mirror’s
load(session_id)returns the full transcript, so a fresh install can pick up the conversation.
How It Works
The store writes local disk first and returns; the record is then handed to a bounded queue drained by a daemon thread that calls mirror.append.
The failure modes never touch the local write:
Configuration Options
SessionMirrorProtocol methods
The runtime_checkable protocol lives at praisonaiagents.session.SessionMirrorProtocol.
| Method | Required? | Signature | Contract |
|---|---|---|---|
append | ✅ | (session_id: str, records: List[Dict[str, Any]]) -> None | Persist the newly-produced turn records. Each record is already timestamped and id-tagged, so a re-append is idempotent (last-writer per record id) — no merge logic required. |
load | ✅ | (session_id: str) -> List[Dict[str, Any]] | Full ordered transcript for a session (empty list if unknown). Used to hydrate a session-id that was never seen locally. |
list_sessions | ⚪ optional | (*, user_id: Optional[str] = None) -> List[Dict[str, Any]] | Enumerate mirrored sessions for a future session list --remote surface. |
DefaultSessionStore(mirror=…)
| Symbol | Type / Default | Description |
|---|---|---|
mirror | Optional[SessionMirrorProtocol] / None | Wire any mirror into the default store. Left None → zero overhead, no background thread. |
flush_mirror(timeout=None) | bool | Block until queued mirror records are processed. Returns True immediately when mirror is None. |
close_mirror() | None | Stop the daemon writer, draining queued records first. |
DEFAULT_MIRROR_QUEUE_SIZE | 1000 | Bound of the background queue.Queue. On overflow, records are dropped-to-log. |
DEFAULT_MIRROR_MAX_RETRIES | 3 | append retries per record with 50ms → 1s exponential backoff before logging and moving on. |
Writing a Mirror
A mirror is three tiny methods — the LoggingMirror from Quick Start is the whole story.
from typing import Any, Dict, List
class LoggingMirror:
def __init__(self):
self._store: Dict[str, List[Dict[str, Any]]] = {}
def append(self, session_id: str, records: List[Dict[str, Any]]) -> None:
self._store.setdefault(session_id, []).extend(records)
def load(self, session_id: str) -> List[Dict[str, Any]]:
return list(self._store.get(session_id, []))Local-First Guarantees
Local disk write always happens first
The mirror is fed after the message is persisted to the session file, off the lock. A mirror outage never reaches the caller as a failure.
A slow/broken mirror never blocks a turn
Records are enqueued onto a bounded queue.Queue (DEFAULT_MIRROR_QUEUE_SIZE = 1000) drained by a daemon thread (praisonai-session-mirror).
Queue overflow is drop-to-log, not deadlock
On Queue.Full, records are dropped with a warning pointing operators at the (future) session sync reconciler; the local file is unaffected.
Transient failures retry with backoff
_SessionMirrorWriter._flush_one retries DEFAULT_MIRROR_MAX_RETRIES = 3 times with 50ms → 1s exponential backoff before logging and moving on.
mirror=None is byte-for-byte the old store
The writer is only instantiated when mirror is not None — no queue, no thread, no import cost.
Post-close appends are rejected
After close_mirror(), further enqueues drop-to-log rather than leaving records stranded with no consumer.
Testing & Reconciliation
Use flush_mirror(timeout=…) to make mirroring deterministic in a test and close_mirror() to drain in a teardown.
store = DefaultSessionStore(mirror=my_mirror)
# ... run turns ...
assert store.flush_mirror(timeout=2.0), "mirror did not drain in time"
store.close_mirror() # stops the daemon writer after drainingBackward Compatibility
mirror=None is the default — existing DefaultSessionStore(...) call sites are byte-for-byte unchanged, with no daemon thread spawned and no dependency added.
Best Practices
Keep append idempotent
Records carry stable ids, so re-appending the same record is last-writer-wins. Don’t add merge logic — rely on the id.
Don't raise on transient failure
The writer retries with backoff and drops-to-log on permanent failure. Let it — raising in append just burns a retry.
Never do heavy work in append
append runs on the single writer thread. Keep it a fast network/DB write so the queue drains and doesn’t back up.
Always call close_mirror() on shutdown
close_mirror() drains in-flight records before stopping the daemon writer. Skipping it can drop the last queued turns.