Liking cljdoc? Tell your friends :D

Programming Model

dvergr's programming model is a small set of primitives, then everything composes through tagged messages. This page is the working reference.

Why discourse? The framework takes the linguistic case seriously — participants are speakers exchanging utterances, a message's :type is its illocutionary force (a directive, an escalation, a probe), and a cheap fork lets an agent run a theory-of-mind probe before it speaks. That lineage — Rational Speech Acts, made reactive — is sketched in discourse-theory.md.

The five primitives

A tagged message is the unit of exchange — {:from :to :type :payload :metadata :reply-to}. :to routes it directly to a participant; :type is an effect tag (:directive/raise-budget, :escalation/budget, :probe/memory), the lever for capability routing.

A capability subscription, (d/subscribe! room participant [:type tag]), makes a participant receive every message carrying that tag, regardless of who it was addressed to — the basis for escalation, monitoring, and fan-out.

Reply correlation is (d/ask room target msg): it posts a message and returns a Spin[Message] bound to the response (await it inside a spin, @-deref at the REPL), so request/response composes the same way whether target is an LLM, a human, or a scripted bot. (ask lives in the discourse algebra — dvergr.core / dvergr.discourse; the higher-level dvergr.clients.client REPL surface drops it in favor of post! + observe.)

A substrate fork, (d/fork-room room {:isolation :ctx}), branches the room's registered systems together; the fork's side effects stay isolated until you merge or discard them through its affine handle.

A GenerationHandle{:token-source :tool-calls :done :cancel!} — is the decision-side (F) primitive: it lets you swap deciders (which LLM, or a scripted policy) without touching the agent loop.

Nothing in the model is special-cased to LLMs; humans, scripts, monitors, and future agent types all live as participants.

Agent programs in the REPL

The room SCI surface also exposes dvergr.agent: immutable, versioned AgentDefs and Rosters are ordinary workflow data, while hire! is the explicit effect that starts a durable Run and returns an opaque handle whose result is an explicit native Spindel Spin. This is the first executable layer for agents constructing specialized teams and parallel workflows for themselves without a hidden mutable roster.

See Agent programs in the room REPL for the API, a provider-free runnable example, and the state/fork contract. The native LLM/tool interpreter is deliberately a follow-up on the same boundary.

The Room + Bus

A Room is a substrate where participants exchange messages. Under the hood it's a dvergr.runtime.bus.Bus — a small pub/sub kernel on spindel's pub/sub primitives — keyed on two routing dimensions:

[:to   <participant-id>]   — direct routing to a participant
[:type <tag>]              — capability routing by message tag

Every message reaches every matching subscription. Same source mailbox, one mult, two pubs (one keyed by :to, one keyed by :type). Both routing dimensions are special cases of the same primitive.

(require '[dvergr.core :as d])

(def room (d/room :my-room))
(d/join room (d/coder {:id :coder}))   ; default inbox subscription on [:to :coder]

Opinionated buffer policy

Per-namespace defaults in dvergr.runtime.bus/*default-buffers*:

Tag namespaceDefault bufferWhat the model says
:messagefixed-buffer 64first-class content; generous backpressure
:directivefixed-buffer 16imperatives; serial; never lose
:escalationfixed-buffer ##Inf-ishmust be answered or explicitly time out
:partialfixed-buffer 256LLM tokens / stream chunks are discrete data — losing one loses information
:ticksliding-buffer 1cadence; latest pulse is the meaningful snapshot
:sourcesliding-buffer 8external readings; recent N tunable per source
:telemetrysliding-buffer 32observation events; UIs want recent, not full backlog

Each line is a programming-model commitment. Two strong opinions:

Escalations cannot be silently dropped. An agent posting :escalation/budget can assume some handler will answer (or the bus health monitor will surface a stuck queue). Forces explicit escalation chains.

Streaming defaults to fixed-buffer. Tokens are discrete data; losing them loses information. UI consumers that only want "current accumulated state" override per-subscription with sliding-buffer 1. This is a consumer policy choice, not a producer commitment.

Dynamic subscriptions

Participants can subscribe to extra tag-channels at any time during their lifetime. A monitor agent that watches every escalation:

(def auditor
  (d/participant
    {:id :auditor
     :on-message
     (fn [_p msg]
       (sp/spin
         (swap! audit-log conj (select-keys msg [:from :to :type]))
         nil))}))

(binding [ec/*execution-context* (:ctx room)]
  (d/join room auditor)
  (d/subscribe! room auditor [:type :escalation/budget]))

Both subscriptions (the default [:to :auditor] inbox AND the extra [:type :escalation/budget]) pump into one merge mailbox the auditor's spin drains. Same on-message handler for both shapes.

The compositional kernel

Agents escalate by posting a tagged message — not by calling a named manager:

(d/post! room {:type :escalation/budget
               :from :coder
               :payload {:remaining 200 :requested 500}})

A policy-bot somewhere has subscribed to [:type :escalation/budget]. It replies with a :directive/raise-budget addressed back to the escalator:

(d/post! room {:to :coder
               :type :directive/raise-budget
               :from :policy
               :payload {:amount 500}})

The coder's existing on-message handler branches on :type and applies the bump. Neither party hardcodes the other's identity — they compose through capability routing.

See examples/scenario_manager_escalation.clj for the full pattern.

The bialgebra

An LLM agent (or any participant) is a bialgebra:

  • F-arc (past, induction): memory + budget + status. F-steps fold incoming messages into memory; compaction folds memory into a summary; budget delta is the cost of folding.
  • G-arc (future, coinduction): spin-race over the inbox + any subscribed tag-channels + budget-threshold + cancel signal. Same race observes every kind of event.
  • Distributive law λ: the GenerationHandle. F is wrapped in a reactive subsystem (token stream, tool-call mailbox, done deferred, cancel fn) so G can race against it.
;; The handle is the named lambda
{:token-source  PAsyncSeq of partial-output deltas (or nil)
 :tool-calls    mailbox/aseq of tool-call requests + results (or nil)
 :done          Deferred resolved with the final result
 :cancel!       0-arg fn to abort generation early}

Four canonical adapters wrap different F shapes:

  • sync-handle — F runs inline in a spawned spin (scripted bots, mocks)
  • future-handle — F runs on a Clojure future, result bridged via Deferred (the current LLM call pattern)
  • external-handle — caller controls when :done resolves (human-in-loop, slow async LLM)
  • streaming-handle — F streams deltas via aseq (real SSE pump)

Plus combinators race-handles (first-arrival wins; losers cancelled) and fallback-handle (primary errors → recover-fn builds secondary).

Swap adapters to change F's shape without touching the agent's G-side spin. The distributive law is named and pluggable rather than baked into one closure.

Built-in directives

llm-agent already handles a small set of tagged messages:

TagEffect
message with no :type (the default branch)run a generation turn
:directive/raise-budgetraise the dollar budget by :payload.dollars (default 0.25)
:directive/cancelflip a cancelled flag; current generation aborts at next race point
:directive/switch-modelmerge :payload into the spec atom (live)
:directive/system-messageinject a :system message into chat-ctx
:probe/memoryreply with the chat-ctx message list

Every directive is a tag the agent chooses to handle. Add a new directive = add a new branch to the case-dispatch. No subclassing, no overriding the "policy". And nothing about it is specific to LLMs — a human participant could handle :directive/cancel just as well (e.g., show a "stop" control in their UI).

Compaction as race-arm (optional)

Compaction normally runs synchronously before each LLM turn. For hot agents that hit compaction often, the race-arm mode runs the expensive LLM-based summarization on a future in parallel with the next generation:

(d/llm-agent
  {:id   :hot-coder
   :spec spec
   :compaction {:auto?    true
                :strategy :race-with-turn}    ; opt-in
   ...})

The current turn uses pre-compaction context; the next turn picks up the compacted state. Saves ~2-3s on the boundary turn.

Substrate forks (yggdrasil)

fork-room {:isolation :ctx} branches every yggdrasil system in the parent ctx — datahike connection, git worktree, KB conn, etc. The worker inside the fork sees a forked DB; merge-room settles each registered system through the canonical recoverable settlement boundary.

(let [fork (d/fork-room room {:isolation :ctx})]
  (d/join fork (d/coder))
  (def reply @(d/ask fork :coder {:content "Refactor src/foo.clj"}))
  ;; Inspect — the fork's writes haven't touched the parent yet
  (if (passes-tests? reply)
    (d/merge-room room fork)
    (d/discard fork)))

Bounded agent programs normally use dvergr.agent/hire!: it opens the canonical world, admits a durable Run, and returns a native result Spin for composition. spawn_agent and propose_change are model-tool adapters over that exact interpreter, selecting :automatic and :review settlement respectively. They do not create Participants, lifecycle logs, or forks of their own.

The theory-of-mind view of a substrate fork: (simulate-reply parent target msg) runs a fork where only the target's behavior matters, then discards — the parent's state is provably untouched.

When to reach for what

Want toUse
Send a message to one named participant(d/post! room (d/message :from :to "..."))
Send to all subscribers of a capability(d/post! room {:type :escalation/budget :payload {...}})
Listen for any escalation regardless of recipient(d/subscribe! room p [:type :escalation/budget])
Wait for one reply(d/ask room target msg)
Fork the room for a what-if probe(d/fork-room room) (shared ctx, ToM-style)
Fork with full git+DB isolation(d/fork-room room {:isolation :ctx})
Construct and compose delegated workimmutable dvergr.agent Rosters + hire! / result-spin
Delegate from a model tool callspawn_agent or propose_change, thin adapters over dvergr.agent/hire!
Swap an LLM's decision shape(llm-agent {:run-turn-fn ...}) using a GenerationHandle adapter
Observe cost / budgetread the chat-ctx :budget-signal (a tracked signal), or dvergr.rooms.stats

Further reading

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