Runtime & Persistence
Two text-runtime layers, one pipeline. The stateless layer is for
single-turn requests. The persistent layer adds thread-aware state
snapshots, candidate buffering, an inactivity sweeper, and the shared
session-end path (/end, timeout, shutdown, API end-session).
Two layers
AgentOutputresponse + crisis + diagnosticsBoth layers run the same runtime stages in the same order. The persistent layer saves the app-owned state snapshot through the configured backend after each turn and merges the next turn's input with that snapshot through explicit runtime helpers.
The browser owns OpenAI Realtime WebRTC audio, while the backend owns
session configuration, memory bootstrap, tool execution, turn recording,
and session finalization. Voice does not drive full text turns
through run_turn / run_turn_stream; finalized Realtime transcripts
are recorded through record_voice_turn(...), and persistent voice
sessions end through the same end_session(...) summarization path as
text. The 20-min inactivity sweeper remains text-session oriented.
Failed transcript finalizations retain a bounded retry marker keyed by the OpenCouch client turn ID and request hash. The browser renews a retained retry handle while it remains available. Non-safety retries expire after two hours without that renewal; verified safety interruptions have a seven-day retry window. Expired markers become bounded tombstones, so a stale client turn ID cannot be replayed as a new transcript, and new turns are rejected when a retry handle already owns a pending turn or a thread already holds 32 protected pending markers.
See Voice for the full picture.
How state accumulates
get_history() on the hot pathbuild_initial_state() emits only the current user turn. The persistent
runtime loads the previous app-owned state snapshot once, then
OpenAITextRuntime combines that snapshot with the new turn input.
This avoids rebuilding the model prompt from public history on every
turn while keeping the public history surface available for API/CLI
reads.
| Field | Persistence behavior | Merge behavior |
|---|---|---|
transcript | Stored in the app-owned runtime state snapshot for API/CLI/audit fallback. | The current user turn is appended to the prior snapshot before the SDK turn; finalization appends the assistant turn before saving. |
| OpenAI SDK session | Stored separately as model-visible short-term conversation history. | The SDK session handles model-visible message history; the app transcript only seeds it when the SDK session is empty. |
session_progress | Saved in the runtime state snapshot. | Runtime turn setup increments turn_count while preserving sibling fields. |
session_memory | Loaded from durable episodic memory into the current turn state. | Dict channels are shallow-merged by apply_state_delta when stages return partial nested updates. |
procedural_profile | Loaded from durable procedural memory each turn. | Rules and recall toggle are replaced by the latest memory-context load. |
exercise_state | Saved in the runtime state snapshot. | Guided-exercise stages shallow-merge active exercise fields so side-turns can preserve continuity. |
memory_control | Saved in the runtime state snapshot. | Pending destructive actions carry across confirmation turns until confirmed or cleared. |
diagnostics | Saved with the final state for debugging. | Runtime stages and side-effect services add their own keys through shallow dict merges or local diagnostic aggregation. |
Turn-scoped fields (crisis, crisis_audit, route, response_text,
response_style, therapeutic_approach, and lookup scratch fields)
are overwritten fresh each turn because they describe one turn's
decisions and reply.
Snapshot compatibility
Runtime snapshots use a versioned JSON envelope with schema_version: 1 and
the app-owned state under state. Existing unversioned snapshots are read as
version 0 and migrated in memory; the next state save writes the current
envelope. Unknown state fields are preserved so independent runtime features
can evolve without a central field registry.
The codec validates persistence-critical channel, routing, safety, and lifecycle values before writes and when snapshots are loaded. Malformed snapshots and unsupported future schema versions fail explicitly rather than being silently mapped to test behavior. The Postgres table schema is unchanged because the envelope is stored inside the existing JSONB value.
Thread lifecycle
# Session 1 — 3 turns + end
$ opencouch --thread-id alice-s1 --user-id alice
> Hi there # turn 1: state snapshot created
> I've been feeling anxious # turn 2: transcript snapshot updates
> Can we do a grounding exercise? # turn 3: exercise state persists in runtime state
> /end # feedback prompt → summarize → episodic arc written
# Session 2 — same user, new thread
$ opencouch --thread-id alice-s2 --user-id alice
> Hey # first-turn catch-up fires: "Last session (anxiety)..."
# alice's semantic facts + procedural rules visible| Event | What happens |
|---|---|
| First turn | State snapshot created. build_initial_state() provides defaults; opencouch_active_sessions row registers the active session. |
| Subsequent turns | Persistent runtime loads the prior state snapshot. Only the new user turn is emitted into turn input. The session row's last_active_at updates. |
/end | Optional feedback prompt → record_session_feedback() → end_session() → summarize_session triggers service-backed episodic summarization/persistence → commit_session_memory triggers service-backed promotion of held candidates → active-session row deleted. |
| 20-min inactivity | Background sweeper finds expired session rows and runs the same end_session() flow with the runtime's default LLM client. Held candidates and episodic arcs are still written even if the user never typed /end. |
| Process shutdown | __aexit__ best-effort finalizes anything still open (when finalize_active_sessions_on_close=True, the default). |
Resume after /end | Same thread_id works. Transcript persists. Next turn starts a fresh session and a fresh candidate buffer. |
| Incognito | Runtime-owned durable stores are in-memory. Nothing touches disk. Crisis log + feedback still record (ephemeral). The active-session table is skipped. |
Lifecycle ownership and lock order
| Concern | Owner |
|---|---|
| Per-thread lock creation, identity, affinity, and idle pruning | ThreadLockManager |
| Preparation, expiration, successful-turn tracking, sweeping, and finalization policy | SessionLifecycleService |
| Mutation tokens and persisted active-session mechanics | ActiveSessionManager |
| Durable active-session rows | Active-session store |
| Public API composition and turn execution | PersistentAgentRuntime |
| Voice state construction and voice-only post-turn diagnostics | VoiceRuntimeFacade |
Lifecycle transitions are absent -> active, active -> rotation required,
active -> interrupted, and active/interrupted/rotation required -> finalized.
Expired sessions are finalized only after expiration is rechecked while holding
the per-thread lock.
The acquisition order is fixed:
ThreadLockManager lock
-> ActiveSessionManager mutation scope
-> state and active-session persistence
Lifecycle service methods do not acquire the per-thread lock themselves. Text,
streaming, and voice callers acquire it before preparation and successful-turn
completion. The sweeper performs an unlocked candidate scan, then calls the
public finalizer, which acquires the lock and rechecks expiration so a renewed
session is not finalized. Failed completion retains the existing mutation marker
for recovery, sweeper failures are isolated per thread, and cancellation releases
the lock through async with.
The --user-id flag
# Without --user-id: memory scoped to thread
$ opencouch --thread-id thread-a # facts written to "thread-a" namespace
$ opencouch --thread-id thread-b # can't see thread-a's facts
# With --user-id: memory scoped to user across threads
$ opencouch --thread-id s1 --user-id alice # facts written to "alice"
$ opencouch --thread-id s2 --user-id alice # sees alice's facts from s1Identity and thread fallbacks
PersistentAgentRuntime does not generate thread ids. Its text turn
methods require callers to pass thread_id, and the runtime carries
that value as session_id inside runtime state. Defaults live at the
caller boundary:
| Surface | Missing thread_id | Missing user_id | Memory owner |
|---|---|---|---|
| Runtime API | No runtime fallback; caller must provide one | Accepted as None | user_id if set, otherwise session_id (thread_id) |
| HTTP / WebSocket text API | Request validation fails | Accepted as None | user_id if set, otherwise thread_id |
| CLI text | Generates local-<12 uuid hex> | Persistent mode falls back to the active thread id; guest mode ignores --user-id | user_id if set, otherwise active thread_id |
| Web UI | Blank setup field generates web-<8 random base36> | Blank persistent setup uses web-user; incognito clears user_id | user_id if set, otherwise thread_id |
| Web voice | Reuses the active web thread_id from setup | Persistent mode uses the active web user id; incognito clears user_id | user_id if set, otherwise active thread_id |
The generated thread id is never derived from the user id. The fallback
goes the other direction: when no stable user_id is supplied, memory
ownership falls back to the thread/session id so each thread stays
isolated by default.
WorkflowContext
Runtime dependencies injected as a frozen dataclass. Runtime stages
access via runtime.context.llm_client — not dict access.
@dataclass(slots=True, frozen=True)
class WorkflowContext:
llm_client: BaseLLMClient | None # control-plane LLM (safety, routing, session finalization)
memory_store: MemoryStore # unified read/write across semantic / episodic / procedural
crisis_log_backend: CrisisLogBackend # always-on audit trail
memory_mode: MemoryMode # INCOGNITO / LOCAL / SYNCED
response_llm: BaseLLMClient | None = None # optional response-writing LLM; falls back to llm_client
embedding_provider: EmbeddingProvider | None = None # for hybrid retrieval and write-time indexing
session_memory_buffer: SessionMemoryBuffer | None = None # held candidates until session end
A convenience property control_llm returns llm_client for
stages that just want "the safety / routing / memory model"
without caring whether a separate response model is configured.
Immutability guarantees that no stage can accidentally modify a
shared dependency during a turn. The slots=True flag reduces
memory overhead. Both are free correctness wins.
Active session recovery
For durable runtimes, PersistentAgentRuntime uses
PostgresActiveSessionStore, backed by the opencouch_active_sessions table.
Each row carries the
thread's session_buffer (held semantic / procedural candidates),
max_crisis_level, and transcript_start_index for the current
session. A 20-minute inactivity sweeper auto-finalizes expired
sessions, and __aexit__ best-effort finalizes anything still open
on shutdown — so held candidates and session-end summarization
trigger reliably even if the user never types /end.
In INCOGNITO mode the same flow runs entirely in memory: thread state
and active-session tracking are process-local and no durable session row
is written.
Durable persistence
Postgres is the only supported durable backend for application-owned runtime
state, long-term memory, crisis audit, session feedback, and active-session
recovery. In-memory stores remain the incognito/test path. The SQLite
runtime-state, active-session, crisis-log, feedback, and long-term-memory
implementations have been removed, along with the built-in SQLite memory tools.
OpenCouch does not import, copy, or delete old memory.sqlite3 files; inspect
one with an older release or an external read-only SQLite tool.
Runtime construction accepts four keyword-only configuration groups:
runtime = PersistentAgentRuntime(
storage_paths=RuntimeStoragePaths(
text_session_sqlite_path=".store/text_sessions.sqlite3",
),
persistence_config=RuntimePersistenceConfig.for_shared_backend(
memory_mode=MemoryMode.LOCAL,
persistence_backend="postgres",
database_url=database_url,
text_session_backend="sqlite",
allow_legacy_sqlite=True,
),
dependencies=RuntimeDependencies(default_llm_client=llm_client),
behavior_config=RuntimeBehaviorConfig(),
)
RuntimePersistenceConfig owns backend and database settings,
RuntimeDependencies owns injected services, RuntimeBehaviorConfig owns
operational policy, and RuntimeStoragePaths owns only the separate OpenAI SDK
text-session SQLite path. The old flat constructor keywords are no longer
accepted.
OpenAI Agents SDK text sessions are a separate, model-visible short-term history
surface. The SDK SQLiteSession option may use text_sessions.sqlite3; it is
not OpenCouch long-term memory and remains guarded by
allow_legacy_sqlite=True when it writes to disk. The TUI exposes that path as
--text-session-sqlite-path. When the SQLAlchemy SDK backend is selected, the
runtime validates connectivity and prepares or verifies its schema during
startup so the first text request does not perform lazy DDL.
Key files
| File | Purpose |
|---|---|
agent/runtime/runtime.py | PersistentAgentRuntime — run_turn, run_turn_stream, end_session, record_session_feedback, sweeper, active-session recovery |
agent/runtime/turn.py | build_initial_state, state_to_output, run_agent |
agent/state.py | State fragments plus AgentTurnInputState and AgentState |
agent/runtime/workflow_context.py | WorkflowContext frozen dataclass |
agent/runtime/state_store.py | RuntimeStateStore protocol plus in-memory and PostgresRuntimeStateStore implementations |
agent/runtime/session/store.py | ActiveSessionStore protocol plus in-memory, null, and PostgresActiveSessionStore implementations |
agent/runtime/session_store.py | Separate OpenAI Agents SDK text-session storage using SDK SQLite or SQLAlchemy sessions |
agent/audit/capture.py | Bounded runtime capture seam for minimal safety events |
agent/audit/crisis_log.py | CrisisLogBackend protocol + in-memory implementation |
agent/audit/postgres_crisis_log.py | Primary durable Postgres crisis log with retention purge |
agent/feedback/session_feedback.py | SessionFeedbackBackend protocol + in-memory implementation |
agent/feedback/postgres_session_feedback.py | Primary durable Postgres feedback store |