Skip to main content

Runtime & Persistence

Two entry points, one pipeline. The stateless layer is for single-turn requests. The persistent layer adds thread-aware checkpointing.


Two layers

Same graphSame nodes · same order · same safety
AgentOutputresponse + crisis + diagnostics
The difference is between turns, not within a turn

Both layers run identical graph nodes in identical order. The persistent layer saves state to SQLite after each turn and restores it via reducers on the next.


How state accumulates

No get_history() on the hot path

build_initial_state() emits only the current user turn. The checkpointer restores prior turns automatically via reducers. This eliminated the O(n) transcript deserialization that ran on every persistent turn.

FieldReducerBehavior
historyoperator.addEach turn appends new entries; checkpointer accumulates
transcriptoperator.addfinalize_turn appends a 1-element delta
progress_merge_dictsPer-turn fields merge with cross-turn fields (exercise state)
diagnostics_merge_dictsParallel extractors write independently

Non-reducer fields (response, crisis) are overwritten fresh each turn. routing uses _merge_dicts to preserve cross-turn modality while resetting per-turn mode/route decisions.


Thread lifecycle

session lifecycle
# Session 1 — 3 turns + end
$ opencouch --thread-id alice-s1 --user-id alice
> Hi there                            # turn 1: checkpoint created
> I've been feeling anxious           # turn 2: transcript accumulates
> Can we do a grounding exercise?     # turn 3: exercise state persists via progress reducer
> /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
EventWhat happens
First turnCheckpoint created. build_initial_state() provides defaults.
Subsequent turnsCheckpointer restores accumulated state. Only the new user turn is emitted.
/endOptional feedback prompt → record_session_feedback()end_session() → episodic arc written. Session metadata cleared.
Resume after /endSame thread_id works. Transcript persists. Next turn starts a fresh session.
IncognitoAll four backends in-memory. Nothing touches disk. Crisis log + feedback still record (ephemeral).

The --user-id flag

memory scoping
# 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 s1

WorkflowContext

Runtime dependencies injected as a frozen dataclass. Nodes 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, memory)
memory_store: MemoryStore # unified read/write across 3 namespaces
crisis_log_backend: CrisisLogBackend # always-on audit trail
memory_mode: MemoryMode # persistent | guest | incognito
response_llm: BaseLLMClient | None = None # selectable response-writer (fast/quality)
embedding_provider: EmbeddingProvider | None = None
session_memory_buffer: SessionMemoryBuffer | None = None # held candidates until session end
Why frozen?

Immutability guarantees that no node can accidentally modify a shared dependency during a turn. The slots=True flag reduces memory overhead. Both are free correctness wins.


Key files

FilePurpose
agent/persistence.pyPersistentAgentRuntime — run_turn, run_turn_stream, end_session, record_session_feedback
agent/graph.pybuild_agent_workflow, build_initial_state, state_to_output, run_agent
agent/state.pyAgentState TypedDict with reducer annotations
agent/runtime_context.pyWorkflowContext frozen dataclass