Status: implemented 2026-09-18. The design and its binding revisions were reviewed against the code, and the implementation was reviewed independently. Scope: run tau2-style benchmarks, and in general any multi-turn environment with a simulated counterpart, inside Dvergr's programming model, so every episode is recorded durably, certified, and inspectable.
The tau2 runner (dvergr.benchmarks.tau2.runner) is a host loop. The
conversation never crosses a Room bus, the world is a value in that loop,
harness candidates use a throw-away in-memory Room and chat context, and
results are EDN lines. None of it is a Run, Attempt, or Scorecard, so nothing
is in a room store, simmis cannot show it, and forking an episode at a turn
is not possible.
Dvergr already has the pieces:
DatahikeStore holds many Rooms.discourse.llm/llm-agent). They answer each inbound
message with an :agent-turn Run that is durably admitted and finished,
and they post correlated :_activity rows carrying tool names and inputs.EnvironmentDef,
Evaluator, WorldSetup, Attempt (validated, content-addressed, stored
through PAttemptStore), and Scorecard (experiment/make-scorecard from
{:experiment/job :attempt} cells).What is missing is a certification path whose root Run is a conversation.
evaluation/evaluate certifies exactly one hired AgentDef Run with one
task and one answer.
experiment Room (durable DatahikeStore, one per experiment directory)
├─ ExperimentDef / DatasetDef / EnvironmentDefs (content-addressed, portable)
├─ episode Run :run/kind :episode, actor :environment, one per cell
│ └─ certified Attempt (attempt/id = episode run id) → Scorecard
└─ episode Room (child, :parent-id experiment room, same store)
├─ participants: candidate (:agent) + environment counterpart (:customer)
├─ messages: the dialogue, as ordinary Room messages (thread = greeting)
├─ :agent-turn Runs of the candidate (one per customer message)
├─ :_activity rows: the candidate's tool uses (names + inputs)
└─ environment effect rows: every tool effect with its exact result
dvergr.agent.conversation (new, generic, agent layer). It certifies
conversational episodes:
(episode-spin experiment-room store env-def evaluator candidate opts)
returns a Spin that yields a certified, persisted Attempt;ConversationEnv is a host capability (never exposed to SCI) providing
:initial-world, :agent-tools, :counterpart, :respond (the effect
interpreter), :limits, and the terminal predicate.It reuses environment/make-attempt-receipt, attempt/make-attempt,
attempt/persist!, and experiment/make-scorecard /
persist-scorecard!. It adds no new durable types.
dvergr.benchmarks.tau2.env. This is the tau2 ConversationEnv: the
world, tools, customer, grader, and evaluator, built from the existing
domain map (t2/load-domain).
dvergr.benchmarks.tau2.inspect. Read-only queries over an experiment
store. It lists Scorecards and Attempts, shows an episode transcript
(messages + activities + effects), replays the world, diffs against gold,
and computes pass^k.
The tau2 world value (retail db, or the banking
{:db :agent-unlocked :user-given :allowlist}) is stored in the episode
Room's Spindel context state at [:dvergr.conversation/world]. Tool
execution reads and swaps it with the episode Room's context bound. As a
result:
fork-room :isolation :ctx is copy-on-write, so
fork-at-turn needs no extra machinery (a later step, not in v1);The world is not written to datahike. It is a pure function of the
recorded effects (replay-world in inspect). The Attempt evidence
records the final world hash and the gold hash.
:_activity rows keep tool inputs but deliberately not raw results. So every
environment tool call, from the candidate or from the customer's own tools,
is also posted by the environment as a non-triggering row: from
:environment, to the activity id, typed :environment/effect, with
{:requestor :tool :arguments :content :error :world-hash-after}. These rows
are the source for DB replay, ACTION grading, step/error accounting, and
inspection. Posting through the Room means the store records them like
everything else.
:agent). This is built from the candidate AgentDef,
which is portable data in the experiment:
:harness :dvergr gives a production llm-agent participant. Its
system prompt is the environment's agent prompt, and its tools are the
environment tool map bound to this episode Room. The action space is
:tools or :repl (clojure_eval plus SCI namespace tau2). This is
exactly how Dvergr agents run in Rooms.:harness :reference gives a thin participant implementing tau2's
LLMAgent, with one model step per protocol step. It starts and
finishes its own :agent-turn Run per inbound message, so the recording
is uniform.AgentDef is {:program {:kind :llm ...} :model-policy ... :metadata {:conversation/harness ... :conversation/action-space ...}}.
Its content hash is the Scorecard candidate identity.:customer). This is a trusted environment participant
wrapping the user simulator. It sees the dialogue role-flipped, like
upstream, and executes its own tools through the same effect interpreter.
It replies in the greeting's thread, so every candidate turn is a
same-thread follow-up. When it emits a stop token it resolves the episode.Admission. run/start! records an :episode Run in the experiment
Room (trigger: a durable "episode opened" message) with provenance
:run/agent-def-hash, :run/program-kind :llm, and
:run/interpreter-version.
Setup. The environment creates the episode Room (same store,
:parent-id the experiment Room), installs the initial world (the
WorldSetup equivalent), joins :agent and :customer, and posts the
greeting from :agent. For tau2 that is the fixed
"Hi! How can I help you today?", delivered through the candidate's
history.
Dialogue. This runs over the bus. The customer DMs the agent; the
agent's :agent-turn Run replies; the customer answers.
Termination. The environment ends the episode when:
###STOP###, ###TRANSFER###,
###OUT-OF-SCOPE###), or the agent does;:timeout-ms expires.Bounds and timeout cancel live candidate Runs (run/cancel-room-runs!),
and the episode waits for them to quiesce.
Observation. This is read from durable facts only. The dialogue comes from room messages, tool traffic from effect rows, and the final world from the episode context.
Verification. The trusted grader runs t2/grade over the
reconstructed trajectory, producing checks such as {:db :action :nl-assertion :communicate} and a reward.
Certification. The episode Run finishes (:completed, or
:failed/:cancelled for infrastructure faults, which are not scored).
Then make-attempt-receipt → make-attempt → attempt/persist! run in
the experiment Room. Evidence is:
{:result {:termination :reward-breakdown}};{:trace {:runs [...] :messages [...ids]}}, covering the episode Run,
agent-turn Runs and message ids;{:world {:final-hash :gold-hash}};{:grading {...}}.Teardown. Participants leave and the episode Room closes. Its store rows remain, which is the recording.
The experiment runs the cells, batched with bounded parallelism exactly like
experiment/run, then builds make-scorecard / persist-scorecard!.
Incomplete experiments leave their Attempts and no Scorecard, consistent
with the existing policy.
The environment itself (tools, prompts, grading) is the verified
transcription and does not change. What changes is the candidate surface.
Room participants see messages decorated with author and time, and
llm-agent appends a "now" note to the system prompt. For tau2 that note
contradicts the frozen world clock (banking is fixed at 2025-11-14). So:
llm-agent gains an opt-in :system-suffix override (nil disables the
now-note). This is an additive option in discourse/llm.clj.:harness :dvergr,
because that is the production surface being measured. The reference
harness builds its own messages and stays tau2-exact.AgentDef metadata and in the
experiment metadata. Results are tau2 "custom" submissions..dvergr/benchmarks/<experiment>/store (datahike on the filesystem, plus
the blob CAS for large Attempt and Scorecard values). An EDN summary
alongside it is optional and only for grep.inspect API.
(scorecards store) and (attempts store {:environment-id ..});(episode store attempt-id), which returns messages, activities,
effects, runs and grading;(replay-world store attempt-id) and (diff-against-gold ...);(pass-hat-k attempts k).The review verified the core shape against the code with a throwaway REPL.
An explicit :episode Run can own a certified Attempt, and nothing needs
program.clj. These corrections are binding:
:attempt/evidence-runs and
:attempt/evidence-messages entry to belong to the Attempt's own Room
(room/store/datahike.clj:284-300, attempt/governance.clj:103-113). The
trace therefore names only the episode Run and its "opened" message. The
episode Room, the agent-turn Run ids, and counts go into evidence as
plain data (:episode {...}), and inspect re-verifies them.llm-agent drops a blank or [SKIP] reply in its self-filter and never
finishes the Run, which then blocks close-room!. Candidates are built
with :room-safe? false.:metadata {:role :user}.
Without it, store hydration infers :assistant for keyword senders, and
the first customer message reached the model as the assistant's own words.llm-agent has no model-step cap, and failed turns post no reply. The
host watches run/watch-runs! :run/finished events for the episode
Room.watch-runs! callback only delivers a promise, because it runs under
the lifecycle lock.paths/set-home! pointed at the
experiment directory and a reset system DB, or in their own JVM..dvergr is never
touched.llm-agent option.
:run-turn-fn wrapper that dissociates
:system-suffix. The wrapper also counts model steps.:compaction {:auto? false}), and the dollar
budget is explicit.:content, with an explicit :seq. Store order is
millisecond-granular and must not be relied on.{:kind :environment/effect :role :tool}, because
arbitrary message metadata keys are rejected by the store.close-room! run
on host threads. Participants do blocking model calls through
generation/future-handle and never block the Spindel drain.:failed and reward 0. The failure class is
recorded, and inspection and metrics filter it out, so a Scorecard can
always be built from one Attempt per cell.:frozen
fork (the default :following mode reads the parent's live values) and a
store that follows the fork (unprovisioned Room forks get none). It
remains a later step.| Namespace | Role |
|---|---|
dvergr.agent.conversation | Generic pieces. It owns the experiment store (a file-backed Datahike database plus a file artifact store), home isolation, episode Run admission and finish, effect rows, and certification. |
dvergr.benchmarks.tau2.episode | One episode. It keeps the world in the episode Room's context and runs the effect interpreter with its bounds. It builds the candidates (the production llm-agent with :tools or :repl, or the tau2 reference loop), the customer participant and a Run watcher, and it does host orchestration and fault certification. |
dvergr.benchmarks.tau2.experiment | Experiments. It builds content-addressed Environment, Dataset and Experiment defs and runs cells on bounded host threads. Resume is keyed by the ExperimentDef content id. It only writes a Scorecard for a fault-free, complete experiment. |
dvergr.benchmarks.tau2.inspect | Read-only views: summary and pass^k, Attempts, episode reconstruction, a printed transcript, verify-world (replay → certified hash), and verify-episode (the graded log against Room rows). |
The implementation review found these defects, all fixed:
The review's hardening changes are also in:
benchmarks/test/dvergr/benchmarks/tau2_rooms_test.clj covers:
:failed and produces no Scorecard;Remaining known gaps:
:_activity rows). The same holds for the
customer model's raw text when it comes with tool calls.HH:mm.tau2/*.program.clj or evaluation/evaluate.Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |