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.
Legacy-only API: removed in 11.3.0
agent.get_transcript(...), working-deque restart-resume and the
FrameworkConfig flags in sections 1-5 are reached through SymfonicAgent.
From 11.3.0, SymfonicAgent(...) and AgentBuilder().build() raise
EntryPointRemovedError before anything is built, with no compatibility
shim, so none of them is reachable on 11.3.0 or later.
symfonic-core==9.12.* is the newest public PyPI line on which both still
construct (private 11.0 to 11.2.6 did too), but it predates the 11.x
features; pinning it does not migrate an 11.x application. Migrate with
Moving to 11.0.
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¶
This is a SymfonicAgent compatibility-facade configuration surface. It
does not configure the native Agent/AgentHost route, whose migration recipe
uses explicit human-interaction ports and human_continuation(...); the
generated native scaffold rejects a non-empty DEV_SQLITE_CHECKPOINT_PATH
instead of accepting a path it cannot consume. A legacy SQLite checkpoint file
is not automatically imported into that native continuation format. See
Moving to 11.0
for the supported composition and its boundaries.
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.
6. Explicit local authenticated HTTP authority¶
ConversationAuthority is a non-FastAPI owner for text-only native chat over
the existing SQLite checkpointer format. This is a local composition slice;
the generated scaffold and default compatibility router do not select it.
Compose inside an async application lifetime, after installing a verified principal resolver:
from symfonic.platform.conversation_authority import ConversationAuthority
from symfonic.platform.conversation_local import LocalConversationFactory
from symfonic.agent.fastapi.shared_chat import create_shared_chat_router
authority = ConversationAuthority(LocalConversationFactory("chats.sqlite"))
reader = authority.transcript_reader()
router = create_shared_chat_router(reader, native_host, authority, app=app)
# Mount this explicit root once. Close authority on application shutdown.
New chats omit session_id; the server issues an id after capability admission.
Subsequent turns require the same verified tenant, sub-tenant and principal.
History and final exchanges live under the existing tenant:sub:session thread
key. GET /chats returns this principal's durable session metadata.
GET /chats/{session_id}/transcript authorizes ownership, then decodes that
same checkpoint through the authority's own saver into the rows
SymfonicAgent.get_transcript returns; it refuses other principals or
namespaces. No SymfonicAgent, provider or second saver is constructed.
Timestamps keep the facade's meaning (the ts of the oldest checkpoint holding
the message): the authority records them when it publishes, so a read decodes
only the authorized checkpoint. Checkpoints filed without that record, by an
earlier release or another writer, are walked oldest first in short pages,
decoded after the saver lock is released, so a long history never holds up
another principal's chat; that walk still costs the reader time proportional
to the history it covers.
mount_selected_chat_root(app, selection="agent", host=..., conversation=authority)
composes authority.transcript_reader() itself when compatibility_reader is
omitted, for either authority, and refuses any other authority type before a
reader is composed. It composes that reader only after the host, typed_resume,
metrics, chat-owner and route-conflict refusals have passed, so a refused mount
leaves the authority's single reader slot free for a corrected mount on a fresh
app.
No shipped composer calls this root yet; a deployment opts in by calling it on
its own outermost app. The generated scaffold mounts
create_kernel_agent_router with its own /api/v1/chats component and
conversation port, which own sessions in the scaffold's tables, so the
selected root would conflict on /chat and /chats there rather than replace
them. examples/server_v1.py stays on the legacy root by its declared
compatibility disposition.
authority.compatibility_reader(LegacyTranscriptReaderFactory(model_provider))
composes the same native reader over a second saver it opens and closes on that
file, without constructing the facade; an authority holds one reader. Called
directly, that reader answers only native-family checkpoints filed for the
scope and session, and refuses an ambiguous scope. It takes no principal, so
it is not an authorization boundary; serve transcripts through the authority.
The direct-read change is breaking and planned for 11.3 (see the
11 transition guide).
flowchart LR
GET["GET /chats/{id}/transcript"] --> AUTH{"ConversationAuthority:<br/>owner, tenant, namespace"}
AUTH -- refused --> R404["404, no read"]
AUTH -- owned --> SLOT{"reader slot"}
SLOT -- "transcript_reader()" --> N1["SqliteTranscriptReader<br/>authority's saver"]
SLOT -- "compatibility_reader(factory)" --> N2["SqliteTranscriptReader<br/>second saver on the file"]
N1 --> ROWS["rows equal to get_transcript"]
N2 --> ROWS
Neither branch constructs SymfonicAgent or loads symfonic.agent.engine.
What this does not establish:
- It does not decide the public
SymfonicAgent/AgentBuildertransition (CORE-388) and does not close CORE-22. - It covers the selected-root transcript read only; it is not evidence that any
other entrypoint is native. The scaffold and
examples/server_v1.pydo not use this root. agent/engine.pyis unchanged and still serves turns elsewhere; it is not dormant or retired.- The direct-read change is unreleased; the 11.2.6 wheel keeps the factory that constructs the facade.
Failed or cancelled first turns discard empty session rows. Publication is idempotent and rejects a changed checkpoint revision.
The selected root exposes /chat, /stream, /stream/typed, /chats and that
transcript read. Only /resume/{pause_token} refuses with 409. It does not bridge
legacy pause tokens to native host tickets, mount legacy CRUD routes, or adopt
unowned historical checkpoints. Attachments refuse before model execution.
Passing metrics_collector= (normally observability().collector, the one
telemetry(services, scope) writes) also mounts GET /metrics/conversations
and /metrics/conversations/{conversation_id} with the legacy root's JSON
fields. Unlike the legacy root, they are not tenant-wide. A conversation's
record is served only to the principal its transcript is served to: another
principal, sub-tenant or namespace in the same tenant gets the same 404 the
transcript gives, and the list holds only the caller's own conversations that
have a record, with limit applied after both filters. /metrics/summary is
not mounted on this root, because a tenant aggregate has no single owner.
The collector keys records by tenant and conversation id only, so ownership is
decided by the conversation authority, not by the collector. Detail calls the
authority's authorize, which refuses exactly where transcript does without
returning it. On PostgreSQL that reads one conversation row and no messages. On
the local SQLite authority the stored checkpoint carries the messages, so the
check still decodes the latest checkpoint; it only skips the second read through
the compatibility reader. The list reads every session the caller owns before
applying limit, as /chats does, so its cost grows with the caller's session
count on both authorities, and on SQLite with their stored messages too.
Only turns whose Agent composes telemetry over that collector are recorded,
and nothing checks that pairing per turn. An Agent without it serves /chat
normally and its conversation answers 404, where the legacy facade records
every turn. The collector answers from its own process memory: after a restart,
or on a worker that did not serve the turn, an owned conversation answers 404
and is left out of the list rather than reported as zero, and counting resumes
from the next turn that process serves.
Without the argument these paths stay unmounted. The legacy selection refuses
it, because the legacy root reads agent.metrics_collector.
/stream is served by the same Agent host and authority with the compatibility
chunk frames. A first streamed turn omits session_id; the issued id is
returned in the X-Session-ID response header and is not added to any frame.
The session row is created before the stream starts, so it is visible in
GET /chats immediately, but the completed turn is persisted only once the
done frame is sent: the exchange is published before that frame. If the first
turn ends in an error frame, the client disconnects, or the response is never
iterated, the empty session is discarded (best effort; a failed discard is
logged). A browser client on another origin can read the header only if the
application's CORS middleware lists it in expose_headers.
Declared divergence from the legacy root: a turn that completes with an empty
answer, or stops at the tool limit, ends in the stream policy's error frame and
publishes nothing. Legacy /stream emits done with final_response='' for an
empty answer. Frame shapes are shared with legacy; full wire parity is not claimed.
/stream/typed runs the same turn as /stream (same refusals, X-Session-ID
issuance, publication and discard) and frames it with the legacy typed wire:
the SSE event is the StreamEvent class name and data its JSON payload.
The exchange is published before the ResponseCompleteEvent frame. It declares
the same divergence: an empty or tool-limit final ends in the error frame where
legacy emits ResponseCompleteEvent with text=''. A pause terminal is refused
with the error frame rather than handing out a token this root cannot resume;
admission already refuses the capabilities that pause. Event classes and order
come from the kernel's typed projection; full wire parity is not claimed.
The default compatibility router remains available with its existing behavior.
The local adapter takes an exclusive process lease at construction and refuses fork-inherited use. Its SQLite file must have one coordinated writer. PostgreSQL, MongoDB, worker fleets, and a route cutover require separate admission evidence. Listing decodes only the latest checkpoint for each owned thread; the saver retains historical full snapshots, so transcript storage follows the existing checkpointer's retention needs. This slice does not migrate or erase old state.
7. Shared PostgreSQL authority and guarded typed resume¶
PostgresConversationAuthority serves the same selected root to many live
workers. Each worker passes its own asyncpg pool; the workers share only the
database. Install the tables once, as the schema owner, before any worker
starts. The installer grants the runtime role USAGE on the schema and row
privileges on the tables. The authority runs no DDL, and it refuses to start
until the installed tables carry the current marker and its login holds those
privileges. A missing privilege or a driver refusal at start-up, including
the start that a first request triggers, reaches the caller as
native_chat_unavailable (503), not as a raw driver error:
from symfonic.platform.conversation_pg import PostgresConversationAuthority
from symfonic.platform.conversation_pg_schema import install_conversation_schema
# migration step, schema-owner connection:
await install_conversation_schema(owner_conn, schema="app", runtime_role="app_runtime")
# each worker, runtime-role pool:
authority = PostgresConversationAuthority(runtime_pool, schema="app")
mount_selected_chat_root(
app, selection="agent", host=native_host, conversation=authority, typed_resume=True,
)
Publication is one transaction gated by a revision compare-and-set on the
conversation row: of two workers that prepared the same revision, exactly one
publishes and the other gets conversation_conflict (409). Ownership
(tenant, sub-tenant, namespace, principal) is checked in the same statement.
These tables are separate from the graph-memory conversation log.
typed_resume=True is refused at mount unless the authority is the
PostgreSQL one, the host is an AgentHost with human_continuation(...), and
the human capability's pause-token redemption and checkpoint port both declare
durability, with redemption shared across replicas. With it, /stream/typed
alone admits that exact capability; /chat and /stream still refuse it. A
pause is recorded against the turn's prepared revision, after the token's
signed session and scope are checked, and before its frame is sent; a token
signed for another session is refused (403) and never framed. The deployment's
pause binding reads the running turn from
symfonic.platform.conversation_turn.current_conversation_turn().
A budgeted deployment (a TokenBudgetTracker is installed) must compose
exactly that human capability plus one telemetry(...) capability billed to
the request tenant. That is the same pair the budgeted resume admits. Each
pausing or completing round must report provider usage. Without the
telemetry capability, a budgeted turn is refused before the model (503). An
unbudgeted turn may compose the human capability alone, or together with
telemetry.
POST /resume/{pause_token} then redeems that pause on any worker. Before the
token is spent it requires the verified principal for the request scope, an
authenticating token (404 unknown, 401 tampered, 410 expired), the token's exact
scope, and a pending pause this root recorded for the same principal, tenant,
session and claim, still at its turn's revision (404 otherwise; 409 if another
turn was published since). The claim is re-checked inside the host's guarded
redemption. The completed answer is published once, with the paused query, to
the transcript that paused, before its ResponseCompleteEvent frame; a second
pause is recorded against the same turn. The answer is read from the kernel
result, so a resumed turn that stops at the tool limit or ends with an empty
answer is refused, as /stream/typed refuses it, and publishes nothing.
Tokens are single use. A resumed turn that fails after its token was claimed
publishes nothing, whether it errors, hits a late 409, or the client
disconnects. Its pause row moves to abandoned under a shielded cleanup. The
spent token never redeems again, and the row stops pinning an empty first-turn
session. If a process dies between the claim and that cleanup, the row stays
pending until the token's signed expiry has passed. After that it no longer
pins the session.
Streamed deltas are sent before publication. If publication then refuses — for example with a 409 because another worker published to the conversation after the claim-time check — the client has already received text that is not in the transcript, and never will be.
With the platform's built-in one-field ask_user registration, the pause is
framed as the generic InterruptEvent, with its {"question": ...} payload
and the pause_token. Recovered continuations frame it the same way. The
answer is {"answer": "..."}. If ask_user is registered with an
AskUserRequest payload schema, the pause is framed as
AskUserQuestionEvent. Without typed_resume, the root keeps refusing
/resume (409) and pause terminals.