Runtime & Persistence
Two entry points, one pipeline. The stateless layer is for single-turn requests. The persistent layer adds thread-aware checkpointing.
Two layers
AgentOutputresponse + crisis + diagnosticsBoth 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
get_history() on the hot pathbuild_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.
| Field | Reducer | Behavior |
|---|---|---|
history | operator.add | Each turn appends new entries; checkpointer accumulates |
transcript | operator.add | finalize_turn appends a 1-element delta |
progress | _merge_dicts | Per-turn fields merge with cross-turn fields (exercise state) |
diagnostics | _merge_dicts | Parallel 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 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| Event | What happens |
|---|---|
| First turn | Checkpoint created. build_initial_state() provides defaults. |
| Subsequent turns | Checkpointer restores accumulated state. Only the new user turn is emitted. |
/end | Optional feedback prompt → record_session_feedback() → end_session() → episodic arc written. Session metadata cleared. |
Resume after /end | Same thread_id works. Transcript persists. Next turn starts a fresh session. |
| Incognito | All four backends in-memory. Nothing touches disk. Crisis log + feedback still record (ephemeral). |
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 s1WorkflowContext
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
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
| File | Purpose |
|---|---|
agent/persistence.py | PersistentAgentRuntime — run_turn, run_turn_stream, end_session, record_session_feedback |
agent/graph.py | build_agent_workflow, build_initial_state, state_to_output, run_agent |
agent/state.py | AgentState TypedDict with reducer annotations |
agent/runtime_context.py | WorkflowContext frozen dataclass |