Skip to content

Conversation persistence — transcript retrieval & restart-resume (v7.27.0)

Status: v7.27.0 — additive, opt-in, default-off. When the flag is off, behaviour is byte-identical to v7.26.2.

symfonic-core keeps three distinct memory surfaces. Conflating them is the root cause of the "what was the first thing I asked?" failure mode, so it is worth naming them up front:

# Surface What it is Faithful for NOT for
1 Transcript The verbatim state['messages'] log, persisted per thread_id by the LangGraph checkpointer Exact ordinal recall ("message N", "first user turn") Semantic search across millions of turns
2 Working memory The bounded in-process recency deque (WorkingLayer) The active session's recent turns Anything older than the deque cap
3 Durable HMS Extracted facts in the semantic / episodic / graph layers "What does the agent KNOW about X" Verbatim replay — episodic is lossy + truncated

v7.27.0 implements surfaces 1 and 2 properly:

  • agent.get_transcript(...) exposes surface 1 for ordinal / time-range retrieval (Q8 — the T14 "first thing I asked" capability gap).
  • Working-deque restart-resume rehydrates surface 2 from surface 1 on session resume (Q7 — a conversation survives a container restart).

Both read the same durable artifact: state['messages'] from the checkpointer. That is why they ship together.


1. Turning it on

Conversation persistence is gated by a single new flag:

from symfonic.agent import FrameworkConfig

config = FrameworkConfig(
    transcript_persistence_enabled=True,         # v7.27.0
    # For a transcript that SURVIVES restart, pick a durable saver:
    dev_sqlite_checkpoint_path="/var/lib/app/checkpoints.sqlite",
    # ...or run with a Postgres graph backend (production).
)

The durable-saver requirement (read this)

The checkpointer selection ladder is, in order:

  1. Postgres graph backend present → durable Postgres saver.
  2. Mongo graph backend present → durable Mongo saver (v8.12.0).
  3. dev_sqlite_checkpoint_path set → durable SQLite saver.
  4. None of the above → in-process MemorySaver — works within the process, lost on restart.

Mongo deployments (v8.12.0)

Before v8.12.0 a Mongo graph backend had no matching checkpointer and fell through to the ephemeral MemorySaver — so resumable sessions and durable transcripts silently died on restart. A Mongo graph backend now wires a durable MongoDBSaver automatically (needs the mongodb extra: pip install "symfonic-core[mongodb]"). ask_user pause tokens are made durable on Mongo too, via a MongoPauseTokenStore. No config change is required — it is selected from the graph backend, exactly like Postgres.

If you set transcript_persistence_enabled=True but configure no durable saver, the engine wires a MemorySaver AND emits a loud one-shot TranscriptPersistenceEphemeralWarning:

transcript_persistence_enabled=True but only an in-process MemorySaver was
selected ... the transcript will NOT survive a restart. Configure a Postgres
graph backend or set FrameworkConfig.dev_sqlite_checkpoint_path ...

This is deliberate. We do not force a durable saver (that would break the zero-config dev path), but we refuse to fail silently — an adopter who flipped the flag to survive restarts must know their transcript is ephemeral.

To silence it intentionally (e.g. a throwaway dev session):

import warnings
from symfonic.agent.warnings import TranscriptPersistenceEphemeralWarning
warnings.filterwarnings("ignore", category=TranscriptPersistenceEphemeralWarning)

Default-off is byte-identical to v7.26.2

When transcript_persistence_enabled=False and ask_user_enabled=False (both defaults), no checkpointer is wired at all — exactly as before v7.27.0. There is no new hot-path cost and no behavioural change unless you opt in.


2. agent.get_transcript(...) (surface 1)

async def get_transcript(
    self,
    scope,                # TenantScope (tenant_id + optional sub_tenant_id)
    session_id: str,
    *,
    index: int | None = None,
    time_range: tuple[datetime, datetime] | None = None,
    speaker: str = "all",                 # "user" | "assistant" | "all"
    limit: int | None = None,
) -> list[TranscriptMessage]

A TranscriptMessage is a lightweight value type (decoupled from LangChain's internal BaseMessage):

@dataclass(frozen=True)
class TranscriptMessage:
    index: int                 # 0-based over the speaker-filtered view
    role: str                  # "user" | "assistant" | "tool" | "system" | "other"
    content: str
    message_id: str | None
    timestamp: datetime | None # checkpoint-granularity (see §2.2)

2.1 By ordinal index — the T14 query

index is 0-based over the speaker-filtered view:

# "What was the first thing I asked?"
first = await agent.get_transcript(scope, "sess-123", index=0, speaker="user")
# -> [TranscriptMessage(index=0, role="user", content="...the literal first user turn...")]

# Last assistant reply:
last_ai = await agent.get_transcript(scope, "sess-123", index=-1, speaker="assistant")
  • Negative indices count from the end (Python-slice semantics).
  • Out-of-range returns [] — it never raises.
  • speaker="all" indexes every message including tool/system rows; speaker="user" / "assistant" index only that role, so index=0, speaker="user" is unambiguously the first human turn.

2.2 By time range — checkpoint granularity

LangChain BaseMessage carries no native per-message timestamp. Wall-clock time lives on each checkpoint (one per superstep / turn). So time_range filters at checkpoint granularity (≈ per-turn), not per-message:

out = await agent.get_transcript(
    scope, "sess-123",
    time_range=(start_dt, end_dt),   # inclusive on both ends
    speaker="user",
)

The timestamp on each returned row is the ts of the checkpoint that first introduced that message. A message we cannot place in time gets timestamp=None and is excluded from a time_range result. If the saver does not support alist (some minimal savers), a time_range query raises TranscriptUnsupportedError (fail-loud) rather than returning a misleading empty list. Ordinal/full reads are unaffected.

index and time_range are mutually exclusive — passing both raises SymfonicAgentError(code="bad_request").

2.3 Semantic search over verbatim turns is a NON-GOAL (v7.27.0)

get_transcript covers index + time-range only. Embedding/semantic search over verbatim turns is intentionally out of scope for this release:

  • The episodic embedding index is lossy (truncated to ~120/800 chars) — searching it does not answer "exactly what was said," and reusing it would re-blur the transcript/HMS line we just drew.
  • Faithful verbatim semantic search needs a separate, non-truncated transcript vector store with its own cost / retention / GDPR profile — a real new surface, not something to bundle here.

If you need semantic search over verbatim turns today, use your own chat-store search. A dedicated transcript_embedding_store remains a clean future additive surface if a concrete need emerges (see v8-roadmap.md).


3. Restart-resume — working-deque rehydrate (surface 2)

The working deque is in-process. On a container restart it is empty and the agent loses its active-session recency context. v7.27.0 restores it by rehydrating from the persisted transcript (not by persisting the deque):

  • On the first run/stream for a (scope, session_id) whose in-memory deque is empty but a durable checkpoint exists, the engine replays the last N user/assistant turns of state['messages'] back into the deque.
  • This runs at most once per thread_id per process (idempotent), and is a pure in-memory replay — no new durable store, no extra hot-path write.
  • Rehydrated rows carry metadata["source"] == "resume_rehydrate" and the same speaker / turn_id shape as live auto-turn writes, so downstream consumers treat them identically.

What resume restores — and what it does NOT

Restored NOT restored
The working-deque recency window (last N turns) Durable HMS facts (never lost — they live in the graph/vector store)
speaker / turn_id metadata on each replayed turn Working-layer graph-persistence nodes (already tenant-durable)
Entity-extraction / full cognitive state

Resume is a recency-window rehydrate, not a full cognitive-state restore. Tool and system messages are skipped (they are not conversational turns), and the replay is capped at the deque's max_entries.


4. PII / retention (GDPR)

get_transcript exposes verbatim user content — the transcript IS personal data. It is subject to the same retention/erasure as the checkpointer: deleting a conversation's thread purges its checkpoints, which removes it from get_transcript. Treat transcript erasure as part of your conversation-deletion cascade (this aligns with the v8.0 GDPR-cascade design where the transcript is surface 1).


5. Forward compatibility

get_transcript and resume derive the thread_id from scope.tenant_id / sub_tenant_id + session_id at a single derivation site. When the v8.0 hierarchical TenantScope.path model lands, that one site updates and both features ride along — no API change. See v8-roadmap.md.