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.

Diagram

Local-First Session Mirror

background

💬 Agent Turn

💾 Local JSON
~/.praisonai/sessions/

📤 Bounded Queue

⚙️ Daemon Writer

☁️ Remote Backend

Quick Start

  1. 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.json and the mirror both receive the turn.

  2. Continue on another machine
    python
    store = DefaultSessionStore(mirror=LoggingMirror())
    records = store._mirror.load("user-42")  # hydrate a session never seen locally

    A 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.

Diagram
SessionMirrorProtocolDaemon WriterBounded Queue~/.praisonai/sessions/DefaultSessionStoreAgentSessionMirrorProtocolDaemon WriterBounded Queue~/.praisonai/sessions/DefaultSessionStoreAgentnever blocks the callerlocal file is authoritativealt[success][transient failure][permanent failure]add_message(…)write JSON (source of truth)OKreturn (turn complete)enqueue(session_id, [record])drain in backgroundappend(session_id, records)OKbackoff 50ms → 1s, retry ×3append(…)log error, move on

The failure modes never touch the local write:

Diagram

queue full

ok

success

transient

still failing

permanent

Record persisted locally

Enqueue

Drop-to-log
run Unsupported markdown: codespan later

Daemon append

Mirrored

Retry ×3 with backoff

Drop-to-log

Configuration Options

SessionMirrorProtocol methods

The runtime_checkable protocol lives at praisonaiagents.session.SessionMirrorProtocol.

MethodRequired?SignatureContract
append✅(session_id: str, records: List[Dict[str, Any]]) -> NonePersist 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=…)

SymbolType / DefaultDescription
mirrorOptional[SessionMirrorProtocol] / NoneWire any mirror into the default store. Left None → zero overhead, no background thread.
flush_mirror(timeout=None)boolBlock until queued mirror records are processed. Returns True immediately when mirror is None.
close_mirror()NoneStop the daemon writer, draining queued records first.
DEFAULT_MIRROR_QUEUE_SIZE1000Bound of the background queue.Queue. On overflow, records are dropped-to-log.
DEFAULT_MIRROR_MAX_RETRIES3append 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.

python
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.

python
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 draining

Backward 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.

Related