How to score your agent's behavior with eval
Murmur's eval system lets you run your capsule against a dataset of test cases and automatically score each session. Scores are written to hooks/eval.jsonl beside trace.jsonl in each session's records directory, where the capsule's own commands cannot change them, and can be compared across runs with mur eval diff. This guide shows how to wire up murmur/hook-eval, write a dataset, run it, and interpret the results.
The relevant manifest options are:
| Option | Controls |
|---|---|
| observability.eval.scorers | List of scorer configurations; at least one required to activate the hook |
| observability.eval.dataset_id | Label applied to every dataset_run record in eval.jsonl |
Step 1 — declare murmur/hook-eval in the manifest
murmur/hook-eval is a hook artifact — it runs at fixed lifecycle points and writes scores. Create a murmur.yaml file with runtime: hook and configure scorers under observability.eval:
name: my-agent
version: "0.1.0"
artifacts:
- name: murmur/driver-anthropic
version: "0.19.0"
runtime: driver
gateway:
endpoint: https://api.anthropic.com
api_key: ${ANTHROPIC_API_KEY}
- name: murmur/hook-eval
version: "0.11.0"
runtime: hook
capabilities:
filesystem:
records: true # the hook writes the session's eval.jsonl beside its trace; hooks get no filesystem by default
- name: murmur/tool-read
version: "0.2.0"
runtime: tool
capabilities:
filesystem:
allow:
- .
inference:
transport: http
model: claude-sonnet-5
driver:
artifact: murmur/driver-anthropic
observability:
eval:
dataset_id: my-dataset
scorers:
- type: exit_ok
name: success_check
- type: max_turns
name: turn_limit
max: 5
- type: max_tokens
name: response_size
max: 3000
- type: tool_sequence
name: tool_order
expected: [tool-read]
name: my-agent
version: "0.1.0"
artifacts:
- name: murmur/driver-openai
version: "0.19.0"
runtime: driver
gateway:
endpoint: https://api.openai.com/v1
api_key: ${OPENAI_API_KEY}
- name: murmur/hook-eval
version: "0.11.0"
runtime: hook
capabilities:
filesystem:
records: true # the hook writes the session's eval.jsonl beside its trace; hooks get no filesystem by default
- name: murmur/tool-read
version: "0.2.0"
runtime: tool
capabilities:
filesystem:
allow:
- .
inference:
transport: http
model: o3-mini-high
driver:
artifact: murmur/driver-openai
observability:
eval:
dataset_id: my-dataset
scorers:
- type: exit_ok
name: success_check
- type: max_turns
name: turn_limit
max: 5
- type: max_tokens
name: response_size
max: 3000
- type: tool_sequence
name: tool_order
expected: [tool-read]
name: my-agent
version: "0.1.0"
artifacts:
- name: murmur/driver-deepseek
version: "0.17.0"
runtime: driver
gateway:
endpoint: https://api.deepseek.com
api_key: ${DEEPSEEK_API_KEY}
- name: murmur/hook-eval
version: "0.11.0"
runtime: hook
capabilities:
filesystem:
records: true # the hook writes the session's eval.jsonl beside its trace; hooks get no filesystem by default
- name: murmur/tool-read
version: "0.2.0"
runtime: tool
capabilities:
filesystem:
allow:
- .
inference:
transport: http
model: deepseek-r1
driver:
artifact: murmur/driver-deepseek
observability:
eval:
dataset_id: my-dataset
scorers:
- type: exit_ok
name: success_check
- type: max_turns
name: turn_limit
max: 5
- type: max_tokens
name: response_size
max: 3000
- type: tool_sequence
name: tool_order
expected: [tool-read]
What each scorer does:
| Scorer type | Passes when |
|---|---|
exit_ok |
The session ended with status: ok: its task completed, rather than failing, spending inference.max_turns, hitting a spend ceiling or being canceled. See Status and exit code |
max_turns |
The session used at most max inference turns |
max_tokens |
Total input + output tokens did not exceed max |
tool_sequence |
The expected list is a subsequence of the observed tool calls (same order, gaps allowed) |
The name field for each scorer is the key that appears in eval.jsonl score records.
Step 2 — install dependencies
mur install
Different ways to install artifacts
mur install fetches an artifact from the source of its scope. murmur/ artifacts come from
the built-in source, murmur-nexus/default-artifacts, with no configuration.
Option A — name the source of a scope in ~/.murmur/config.yaml:
registry:
sources:
- repo: <owner>/<repo>
token: "${GITHUB_TOKEN}"
Then install by full name, with or without a version:
mur install <owner>/<artifact-name>@<version>
Option B — pass a full GitHub reference and skip configuration entirely:
mur install github:<owner>/<repo>@<tag>
See Installing artifacts to learn more.
Step 3 — write a dataset
A dataset is a JSONL file — one JSON object per line, one line per test case. The conventional location is eval.jsonl in the project directory — the same folder as murmur.yaml, where mur eval run looks by default.
Create one task file in the project root:
task.md:
Read murmur-runtime/MURMUR.md, then draft a one-sentence summary of the Murmur project. Reply with only the final sentence.
The task starts with a tool call — the agent reads MURMUR.md (the capsule environment reference the runtime writes for every agent session, which tool-read reads at murmur-runtime/MURMUR.md) before answering. This is important: tool_sequence applies to every case in the dataset, so every case must call the tool. Grounding the task in a file read makes that natural.
The same task runs for all four cases — what differs is the case_id, which names the scorer each case is primarily meant to verify.
Then create eval.jsonl:
{ "case_id": "exit-check", "task_path": "task.md" }
{ "case_id": "turns-check", "task_path": "task.md" }
{ "case_id": "size-check", "task_path": "task.md" }
{ "case_id": "tool-check", "task_path": "task.md" }
Each task_path points to a file that is copied to the session's task.md before the session starts. exit-check verifies success_check (clean exit); turns-check verifies turn_limit (read + answer takes two inference turns, well within five); size-check verifies response_size (MURMUR.md content adds roughly 1200 tokens to the second turn, bringing the total to ~2450, within the 3000-token budget); tool-check verifies tool_order (tool-read was called before the answer).
The case_id is injected into the hook environment as MURMUR_CASE_ID and appears in every dataset_run record — it's how you identify each case in the output.
Step 4 — run the dataset
Run from the directory containing murmur.yaml:
mur eval run
Both arguments default: capsule to the current directory and dataset to eval.jsonl. To override either:
mur eval run ./other-capsule # explicit capsule
mur eval run --dataset ./other.jsonl # explicit dataset
mur eval run ./other-capsule --dataset ./other.jsonl # both explicit
mur eval run runs one capsule session per case, sequentially. For each case it:
- Stages the session — allocates a per-session work folder (
workdir/ses_xxx/) and records directory, and loads artifacts - Copies
task_pathtotask.mdin the session's runtime directory - Launches the session
- Reads
hooks/eval.jsonlfrom the session's records directory (written by the hook at session end). A case whose hook wrote none — one declaredscope: .instead ofrecords: true— reportsno_scoresand a warning naming the grant - Prints a result line
Output as cases complete:
Running 4 case(s) …
case: exit-check
result: pass session: ses_6801f81dd28b4a9daf434e8324c4793e
case: turns-check
result: pass session: ses_7902a92ee39c5baebf545f9435d5804f
case: size-check
result: pass session: ses_9b14c03df47e6a1c84523f7896d5920f
case: tool-check
result: pass session: ses_ab25d04ef58f7643bce9fbd67da681c3
After all cases:
── Summary ──────────────────────────────────────
pass: 4/4
exit-check pass response_size=1.00 success_check=1.00 tool_order=1.00 turn_limit=1.00 (/path/to/workdir/ses_6801...)
turns-check pass response_size=1.00 success_check=1.00 tool_order=1.00 turn_limit=1.00 (/path/to/workdir/ses_7902...)
size-check pass response_size=1.00 success_check=1.00 tool_order=1.00 turn_limit=1.00 (/path/to/workdir/ses_9b14...)
tool-check pass response_size=1.00 success_check=1.00 tool_order=1.00 turn_limit=1.00 (/path/to/workdir/ses_ab25...)
It then prints the run's comparison table, the one Step 8 reads, and the run's id, run: evr_….
All four cases pass every scorer. Reading MURMUR.md takes one inference turn (the tool call) and answering takes a second, so turn_limit sees two turns. The tool result injects MURMUR.md's content into the second turn's context, which pushes total tokens to around 2450 — that's why max_tokens is set to 3000 rather than something tighter.
Step 5 — read the eval output
Each case produces its own hooks/eval.jsonl in the session's records directory. Inspect the most recent session, or any session by suffix:
mur eval show # most recent session
mur eval show 6801 # session whose ID ends in "6801"
Output:
── Eval: /home/me/.murmur/records/home/me/eval-capsule/workdir/ses_6801.../hooks/eval.jsonl ──────
── Scorers ──────────────────────────────────────
response_size 1/1 pass (100.0%)
success_check 1/1 pass (100.0%)
tool_order 1/1 pass (100.0%)
turn_limit 1/1 pass (100.0%)
── Overall ──────────────────────────────────────
result: pass
case: exit-check
dataset: my-dataset
── Score summary ────────────────────────────────
response_size 1.0000
success_check 1.0000
tool_order 1.0000
turn_limit 1.0000
── Worst events ─────────────────────────────────
(no failing events)
The Worst events section lists the individual scoring events that failed — useful for diagnosing which specific turn or tool call tripped a scorer. Here it's empty because all four scorers passed.
For programmatic use:
mur eval show --json
{
"overall": "pass",
"scorers": {
"response_size": { "pass": 1, "fail": 0, "total": 1, "pass_rate": 1.0 },
"success_check": { "pass": 1, "fail": 0, "total": 1, "pass_rate": 1.0 },
"turn_limit": { "pass": 1, "fail": 0, "total": 1, "pass_rate": 1.0 }
},
"dataset_run": { "overall": "pass", "scores": { "response_size": 1.0, "success_check": 1.0, "turn_limit": 1.0 } }
}
Step 6 — compare two eval runs
After re-running the dataset (for example, after changing the system prompt or swapping the model), compare the new results against a baseline to check for regressions:
mur eval diff 21f7 93a8
Output when both runs pass all scorers:
Scorer Run A Run B Delta
──────────────────────── ────────────── ────────────── ──────────────────────────
response_size 100.0% 100.0% =
success_check 100.0% 100.0% =
tool_order 100.0% 100.0% =
turn_limit 100.0% 100.0% =
overall pass pass
If a scorer regressed — for example response_size dropped from 100% to 0% after a prompt change — the delta would show -100.0pp (A better) and overall would change to fail. That is the signal to investigate.
Step 7 — interpret a failing scorer
Each failing scorer tells you something specific:
| Scorer fails | What it usually means |
|---|---|
exit_ok |
The session errored out or hit the turn cap — check the trace for the exit status |
max_turns |
The agent needed more turns than the budget allows — consider raising max or tightening the system prompt |
max_tokens |
The session used more tokens than the budget — the agent is producing verbose output; try a more constrained prompt or a lower-verbosity model |
tool_sequence |
The agent called tools in the wrong order or skipped a required tool — check the inference events in the trace |
When a scorer fails unexpectedly, open the companion trace for the same session:
mur trace show
Different ways to identify a session
mur trace show with no argument reads the most recent session:
mur trace show
To name another one, pass an ordinal counting back from the newest (@2), the last 4 or more
characters of its ID (3e4b), the full ID, or a path to its trace.jsonl:
mur trace show @2
mur trace show 3e4b
mur trace show ses_6801f81dd28b4a9daf434e8324c4793e
mur trace show path/to/trace.jsonl
Use --workdir <path> if your session directories are not under ./workdir. Every command
that names a session takes the same addresses — see
Session addresses.
Other trace exploration commands
mur trace has four subcommands for exploring session output:
mur trace show — print the full trace for a session to the terminal.
mur trace steps — show a turn-by-turn summary of what the agent did in a session. Pass --verbose to include a truncated summary of each tool's input.
mur trace diff — compare the traces of two sessions side by side, or with no arguments the two most recent. Useful for spotting behavioural regressions between runs.
mur trace report — generate a structured summary report from a session's trace. Covers token usage, tool calls, latency, and other session-level metrics.
Step 8 — compare a harness change by whether the work passes
Scorers judge how a session behaved. To judge whether a change to the harness — a skill, a hook,
the system prompt — makes the agent's work pass, give each case a start folder and a check
command, and run the changed manifest as a variant against the same starts.
Copy the capsule directory and make the change in the copy:
cp -r . ../variant
$EDITOR ../variant/murmur.yaml
Both manifests must name the same inference.model, and the check's binary must be in both
manifests' capabilities.shell.allow: the check runs under the
session's own containment.
Give a case a start, a folder copied into each session's work folder before it starts, and a
check, a command run in that work folder after the session ends:
eval.jsonl:
{ "case_id": "fix-parser", "task_path": "tasks/fix-parser.md", "start": "cases/parser", "check": "make test" }
{ "case_id": "fix-config", "task_path": "tasks/fix-config.md", "start": "cases/config", "check": { "command": "bash check.sh", "files": "checks/config", "timeout_secs": 120 } }
check.files is copied into the work folder just before the command runs, so a test the agent
must not see stays out of the session. The command, its files and its output never reach the
agent, the provider or the trace. Exit 0 is pass.
Run every case three times on each manifest:
mur eval run --variant ../variant --repeat 3
Each run starts from a fresh copy of the case's start, checked against the source's digest
before launch, with the same task. Repeats alternate which manifest goes first. The run ends with
the comparison and the run's id:
run: evr_01a16d24d4d073c1bed590ba1bf056d3
hint: mur eval diff --run evr_01a16d24d4d073c1bed590ba1bf056d3
Read the comparison again at any time; --run alone reads the most recent run:
mur eval diff --run
── case: fix-parser ────────────────────────
baseline variant delta (variant − baseline)
check 1/3 pass 3/3 pass +2
turns 4 (3–6) 3 (3–4) -1
input tokens 21840 (18210–30117) 15022 (14873–16540) -6818
cached tokens 9120 (0–11002) 8890 (7310–9020) -230
cache write tokens 4102 (3980–4570) 4022 (3870–4190) -80
output tokens 812 (640–1210) 590 (512–700) -222
time 21.4s (18.0s–33.9s) 15.2s (14.1s–17.3s) -6.2s
mur trace diff /home/dev/.murmur/records/…/ses_01a1…/trace.jsonl /home/dev/.murmur/records/…/ses_01a2…/trace.jsonl
The check row says which manifest's work passes: here the variant fixed the parser in all three
runs and the baseline in one. Each measure is the median over the repeats with the min–max
spread, and the delta is the variant's median minus the baseline's. Run the printed
mur trace diff line to see where a pair of sessions diverged. A total block after the cases sums
every run.
The comparison is kept as a 0600 record beside the session directories:
ls -l ~/.murmur/records$(pwd)/workdir/evr_*.json
For a program deciding whether to keep a change, mur eval diff --run --json prints the same
comparison as one JSON object. See mur eval diff --run
and the eval-run record.
Summary
| Step | What you do |
|---|---|
Declare murmur/hook-eval with runtime: hook |
Activates the eval hook for this capsule |
Grant it capabilities.filesystem.records: true on its own entry |
Lets it write eval.jsonl into the records directory beside the trace; hooks have no filesystem access by default (see Hook capabilities) |
Declare murmur/tool-read with runtime: tool |
Gives the agent file-read capability; required for tool_sequence cases |
Configure observability.eval.scorers |
Defines the scoring criteria — all scorers run for every case |
Write eval.jsonl |
One case per line, each with a case_id and task_path |
mur eval run |
Runs all cases against eval.jsonl and prints a pass/fail summary |
mur eval show |
Shows which scorers passed, which failed, and why, for the most recent session |
mur eval diff |
Compares the two most recent runs to check for regressions |
Give cases a start and a check |
Judges each session by whether its work passes, under the session's own containment |
mur eval run --variant ../variant --repeat 3 |
Runs every case on both manifests from the same start |
mur eval diff --run |
Prints, per case and in total, check passes, turns, tokens and time for both manifests |