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.

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:

  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.

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/AgentBuilder transition (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.py do not use this root.
  • agent/engine.py is 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.