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:
- Postgres graph backend present → durable Postgres saver.
- Mongo graph backend present → durable Mongo saver (v8.12.0).
dev_sqlite_checkpoint_pathset → durable SQLite saver.- 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, soindex=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/streamfor a(scope, session_id)whose in-memory deque is empty but a durable checkpoint exists, the engine replays the last N user/assistant turns ofstate['messages']back into the deque. - This runs at most once per
thread_idper 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 samespeaker/turn_idshape 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.