Liking cljdoc? Tell your friends :D

dvergr state model

Where every kind of state lives, whether it has value semantics (CoW-forkable via the spindel execution context + yggdrasil workspace), and how this behaves when dvergr is embedded as a library. Companion to doc/configuration.md.

Three tiers

Tier 1 — Value-semantic / forkable

Lives in the spindel execution context and the yggdrasil composite workspace (registered Yggdrasil systems). Forking a room with :isolation :ctx creates one canonical Spindel ForkHandle, which copy-on-write copies the context and branches the workspace as a unit; settling that handle merges or discards it.

  • The chat Datahike DB (one DB, system "dvergr-chat-db"): messages, rooms (:chat/*), actors/agents (:actor/*), tool-uses, ledger (budget), knowledge (:entity/*), code, tasks, proposals. Branches on fork.
  • Git worktrees: the agent's file edits; branch on fork.
  • In-context signals: per-chat messages/budget/status signals, the live room registry ([:dvergr/rooms]), processes, etc. CoW-copied with the context.

Almost all durable, meaningful state is here — so it gets value semantics for free.

Tier 2 — Filesystem, NOT value-semantic

Everything under .dvergr/ (gitignored; config.local.edn too). The state root resolves paths/set-home!DVERGR_HOME env → ./.dvergr (project-relative).

  • Datahike store bytes (.dvergr/db/), per-sim DBs, intake transcripts, the log. The DB's content is Tier 1 (branchable); its store directory is just where bytes land. (Fulltext search is moving to a native Datahike :scriptum secondary index maintained inside the DB store — no separate Lucene directory.) (Agent prompts used to live here as .dvergr/agents/<id>.md; they now live in the actor row — Tier 1. See "Personas" below.)

Tier 3 — Transient daemon (in-memory, rebuilt on restart, never forked)

The Daemon record fields (config, execution-ctx, discourse-room, telegram-ch, http-server, status), current-daemon, the room-turns cancel registry (now in dvergr.agent.turn), live participants/spins, and the stats/diff caches. All a projection of Tiers 1–2 — agents/rooms rehydrate from Datahike actor rows + the registry, budget from the ledger, worktrees re-open. This is the "fine to be stateful" part: losing it loses nothing durable.

The chat-state ChatContext (per [room, agent])

The working state an agent reasons with is one cached ChatContext per room-agent pair (dvergr.agent.room-context/ensure-ctx!) — itself a fold of Tier 1:

  • messages-signal — deltaable vector of the conversation (in-memory projection)
  • budget-signal{:total :used :by-type} token accounting
  • status-signal:active / :paused / :completed
  • db-conn — the dvergr-chat-db connection (in a fork: the branched one)
  • sci-component — a stable Spindel component reference selecting the agent's SCI sandbox (clojure_eval world) in the current execution context

The agent's own ctx is :durable? false — signal-only. The room store's bus→store listener is the lone durable writer for the conversation. So the durable chat state is the Datahike DB (Tier 1); the ChatContext is its reactive fold plus the per-session sandbox.

What a :isolation :ctx fork actually forks

fork-room calls spindel.yggdrasil/fork!, retains its process-local ForkHandle, and branches every selected registered Yggdrasil system as a unit:

  • Datahike dvergr-chat-db → branched — messages, Runs, certified Attempt indexes, KB, ledger, proposals, all isolated until merge. Exact Attempt payloads use the immutable global blob CAS; only a branch's typed reference makes one part of that world. This is the whole semantic chat state, forked through yggdrasil.
  • git worktree → a fresh worktree; the agent's file edits are isolated.
  • muschel shell session → its env-atom is CoW-forked alongside, so a worker's cd / shell state can't leak to the parent.
  • per-agent SCI sandbox → the SCI interpreter is a Spindel world component. Forking the execution context forks its heap (vars, atoms, bindings, and continuations) from the effective parent state. The child reuses the stable ChatContext identity while resolving an independent interpreter; (def x …) in one fork is invisible to its parent and siblings. Discard unregisters the child realization rather than merging transient interpreter state.

Injected host capabilities follow a stricter rule than ordinary SCI values. They never close over a Room, Datahike connection, Geschichte workspace, or filesystem. Each interpreter component owns a stable capability identity whose serializable binding lives in the Spindel execution context. A copied function therefore resolves that identity in the currently selected descendant world at call time. This is why a helper defined before a fork cannot write back into its parent even though the helper's SCI function value itself was inherited. Namespace refresh after projection exposes new APIs; it is not the isolation mechanism.

The fork's conversation is seeded from the branched store, so the agent sees inherited (pre-fork) messages plus its own — exactly what the UI shows. merge-room and discard settle that same handle exactly once. A fork is thus a fully substrate-isolated world you can review (diff) before adopting or discarding. The live handle is process-local; dvergr.discourse/fork-descriptor is the portable projection used by Runs, proposals, UIs, and audit state.

ChatContext SCI API migration

ChatContext no longer exposes a raw :sci-ctx field. Embedders must call dvergr.chat.context/sci-context-in with the intended execution context, or sci-context while that context is bound. The record now carries :sci-component, which is an opaque stable reference and must not be passed to SCI directly. Tool contexts still use :sci-ctx; supply the interpreter returned by one of those resolver functions.

Personas / agent config — managed, not files

An agent's model/provider/skills/budget/description live in its actor row (Tier 1), and so does the system prompt (:actor/system-prompt), edited only through the API (dvergr.agent.ops/update-agent!, the web textarea, the REPL).

Rationale: agents should mutate dvergr through controlled surfaces (the REPL, our git-backed bash, the API) — not by scribbling unmanaged files outside the substrate. Putting the prompt in the actor row makes it:

  • value-semantic — a prompt edit inside a :ctx fork stays on that branch;
  • versioned — it lives in the Datahike branch history with everything else;
  • uniform — one source of truth for all agent settings.

The built-in resources/agents/<id>.md files remain as read-only seeds/defaults shipped on the classpath (and tracked in dvergr's own git). persona/resolve-prompt reads the actor row first, falling back to the built-in resource.

Editing surfaces: the web config textarea and ops/update-agent!/the REPL write the row directly; the TUI's agent-config view offers p to edit the prompt in $EDITOR (dumped to a throwaway tmpfile, saved back to the DB) via spindel-tui's :with-suspended. The tmpfile is transient editing scratch, never state.

As a library

No client/server boilerplate; the host owns its state root and config:

  • State root: paths/set-home! / DVERGR_HOME / ./.dvergr.
  • Config: config.local.edn (or $DVERGR_CONFIG), loaded once by dvergr.substrate.config/load-config; it is a seed — after first boot the Datahike actor rows are authoritative.
  • Boot: (config/load-config)(daemon/start! (config/daemon-config)).
  • APIs: dvergr.core (room/participant/ask/fork facade) and dvergr.clients.client — the nREPL client (c/start! to attach or boot a lite daemon; c/room/c/spawn for a Room handle; c/fork/c/merge!).

The host owns .dvergr/, config.local.edn, and any agent overrides; the library owns the rest, and all durable meaning is in the forkable Datahike DB — the property you want from a dependency.

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close