caliper

Evaluation and observability harness for LLM agents: task suites, trajectory-level grading, run store, regression diffs, and a CI gate that exits non-zero.

Stars
1
Language
Python
Updated

Caliper

An evaluation and observability harness for LLM agents. It runs a task suite against your agent, scores the whole trajectory rather than just the final answer, stores every run as a file, and diffs two runs to surface regressions.

It exits non-zero when a task goes pass -> fail, so it works as a CI gate:

# .github/workflows/eval.yml
- run: caliper run suites/reference.yaml --agent reference-v2
- run: caliper compare latest latest~1 --fail-on-regression
SUITE: reference-agent v2   vs   v1
-------------------------------------------
task success      92.0%      36.0%   +56.0
steps (median)      2.0        4.0    -2.0
cost / task      $0.020     $0.035    -43%
p95 latency        0.3s       0.5s    -45%
tool-error rate    6.7%      60.3%   -53.6

REGRESSIONS (0)
  none

Those numbers are real output from results/reference-v2-vs-v1.txt, produced by the two versions of the built-in reference agent on the 25-task reference suite. Reproduce them in about ten seconds with no API key:

pip install -r requirements.txt
export PYTHONPATH=.
python -m caliper.cli run suites/reference.yaml --agent reference-v1 --offline
python -m caliper.cli run suites/reference.yaml --agent reference-v2 --offline
python -m caliper.cli compare latest latest~1

Why trajectory scoring

Here is one task from that run. Both agent versions returned the same answer:

versionfinal answerstepscostnumeric_tolerancetask
v1425$0.045passFAIL
v2422$0.018passpass

A final-answer grader cannot tell these apart, because on the only axis it measures they are identical. v1 called the knowledge base twice for an arithmetic question, failed both times, then computed the answer and computed it again to "double-check". It is 2.5x the cost and 2.5x the latency for the same output, and it is one traffic spike away from being a production incident.

Caliper fails that task on step_efficiency and no_redundant_calls. That is the whole argument for the project.

Install

git clone <this-repo> caliper && cd caliper
python3.11 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
pip install -e .          # optional: puts `caliper` on PATH
cp .env.example .env      # only needed for the llm_judge grader

Everything except llm_judge and live agent runs works with no API key.

The adapter contract

This is the entire coupling between Caliper and your agent:

class AgentAdapter(Protocol):
    name: str
    version: str
    def run(self, task: Task) -> Trajectory: ...

No framework, no base class, no monkeypatching of your LLM client. You record steps and hand back a trajectory:

from caliper.adapter import TrajectoryRecorder

class MyAgent:
    name = "my-agent"
    version = "v3"

    def run(self, task):
        rec = TrajectoryRecorder(task.id)
        with rec.step("llm", "plan") as s:
            resp = self.client.complete(messages)
            s.output = resp.text
            s.tokens_in = resp.usage.prompt_tokens
            s.tokens_out = resp.usage.completion_tokens
            s.cost_usd = resp.usage.cost_usd
        with rec.step("tool", "search", input=query) as s:
            s.output = self.search(query)
        return rec.finish(final_output=answer)

rec.step times the block, assigns the index, and captures any exception into step.error before re-raising, so an agent that dies mid-tool still leaves an accurate record of the tool that killed it.

Run it:

caliper run suites/mine.yaml --agent mypkg.adapter:build

Graders

Registry-based, referenced by name from the suite YAML. Every grader is a pure function with the same signature, so a run file can be re-graded later without re-running the agent.

Final answer

graderwhat it checks
exact_matchwhitespace-normalised string equality
numeric_tolerancenumeric comparison, rel and/or abs
contains_allevery required substring present, partial credit
regex_matchsearch or fullmatch
json_schema_matchparses as JSON and satisfies a small schema subset
llm_judgerubric-based via OpenRouter, n_samples majority vote

Trajectory — the ones a final-answer harness structurally cannot provide

graderwhat it checks
step_efficiencysteps taken vs a reference optimum, with slack
tool_choice_precisionwere the tools called the ones actually needed
no_redundant_callssame tool, same arguments, twice
recovered_from_errordid an errored step get followed by a success
cost_budgettrajectory cost against a task-level budget
latency_budgetwall clock against a task-level budget
completeddid the agent terminate normally at all

Adding one:

from caliper.graders.registry import grader
from caliper.types import Score

@grader("answers_in_one_sentence")
def answers_in_one_sentence(task, trajectory, max_sentences: int = 1) -> Score:
    n = str(trajectory.final_output).count(".")
    return Score(name="answers_in_one_sentence", value=1.0 if n <= max_sentences else 0.0,
                 passed=n <= max_sentences, detail=f"{n} sentence(s)")

Import the module from caliper/graders/__init__.py and reference it by name. There is no step 4.

Suites

suite: reference
defaults:
  timeout_s: 15
  cost_budget_usd: 0.025
tasks:
  - id: reverse_looked_up_fact
    input: "Reverse the capital of Japan."
    expected: "oykoT"
    grader:
      - exact_match: {case_sensitive: true}
      - tool_choice_precision
      - step_efficiency
    expected_tools: [lookup, string_op]
    reference_steps: 3
    tags: [composite]

Shipped suites: suites/reference.yaml (25 tasks, exercises every grader), suites/strata.yaml and suites/quarry.yaml for the two sibling agent projects. The sibling adapters import lazily and fail with instructions if the repo is not on the import path, if its environment is broken, or if its constructor needs wiring the adapter cannot guess.

Those two adapters are provisional: Strata and Quarry are being written in parallel, so the adapters bind to whichever conventional entry point those repos expose and tolerate several shapes of trace. They will be pinned once the sibling APIs settle. The reference agent, not those adapters, is what the test suite guarantees.

CLI

caliper run <suite> --agent <name> [--workers N] [--limit N] [--tag t] [--offline]
caliper list                                 recent runs
caliper show <run_id>                        summary plus per-task breakdown
caliper compare <run_a> <run_b>              the table above
caliper compare a b --fail-on-regression     CI gate, exits 1 on any pass -> fail
caliper trace <run_id> <task_id>             step-by-step trajectory dump
caliper report <run_id> --html out.html      standalone HTML report
caliper graders                              list registered graders
caliper prune --keep 50                      delete old runs

Run ids accept a unique prefix, latest, or latest~1.

Exit codes: 0 clean, 1 regressions detected, 2 usage or config error.

Architecture

suites/*.yaml
     |
     v
  runner  --(ThreadPoolExecutor, N workers, per-task timeout)--> adapter --> agent
     |                                                                         |
     |                                      TrajectoryRecorder <---------------+
     v                                              |
  graders <---------------------------------------- Trajectory
     |   final-answer + trajectory
     v
  .caliper/runs/<run_id>.json   (run store)
     |
     v
  compare  -->  regression gate (exit 1)

Concretely: caliper/types.py is the data model, caliper/adapter.py the boundary, caliper/graders/ the scoring, caliper/runner.py the execution, caliper/store.py persistence, caliper/compare.py the diff, caliper/render.py the output, caliper/cli.py the entry point.

Design decisions

Adapters, not framework integration. Caliper could have shipped callbacks for LangChain and friends and got instrumentation for free. Instead the entire coupling is run(task) -> Trajectory. Cost: every agent author writes about ten lines, and Caliper cannot see steps the agent chooses not to record. Gain: Caliper works on a hand-written loop, a framework agent, a remote HTTP service and a shell script, and it does not break when a framework changes its callback API.

Trajectory graders as first-class. Final-answer grading is a strict subset of what matters. The table at the top of this file shows two agents with identical answers on a task and a 2.5x cost difference; only trajectory grading separates them. Cost: trajectory graders need the task author to supply a reference (reference_steps, expected_tools), which is more suite-authoring work than writing an expected string.

Runs are files, not a database. A run is an immutable artifact produced by CI. Files diff, attach to a PR, commit as a pinned baseline, and copy with scp. Cost: no indexed queries, and listing a thousand runs reads a thousand files. caliper/store.py is the only module that would change if that tradeoff flipped.

Regressions exit non-zero. A report nobody reads changes nothing. A gate that blocks a merge changes behaviour. The gate is deliberately narrow: a named task that used to pass and now does not, never a metric that moved the wrong way and never an infrastructure error, because a gate that fires on a rate limit teaches the team to pass --no-verify.

Deterministic output ordering. Results are emitted in suite order regardless of completion order, so two run files diff cleanly.

Limitations

  • LLM-judge variance is real. Re-grading the same response can land in a different bucket, especially near the 0.75/0.50 boundary. Mitigated with temperature 0, a fixed five-point rubric, and n_samples majority vote with agreement reported in the score detail; not eliminated. A comparison that hinges entirely on a few points of llm_judge delta hinges on noise.
  • No distributed execution. One process, a thread pool, one machine. Fine for a suite of a few hundred tasks; not a replacement for a job queue.
  • Timed-out tasks are abandoned, not killed. Python cannot safely kill a thread. The result is recorded immediately and the run proceeds, but the worker may still be running underneath.
  • No live dashboard. Plain text and a static HTML report. Deliberate, but it means no streaming view of a long run beyond the stderr progress lines.
  • A suite is only as good as its author. Caliper measures what you tell it to measure. reference_steps and expected_tools are human judgements, and a badly chosen reference optimum produces a confidently wrong efficiency score.
  • Cost figures depend on OpenRouter's reported usage. When the provider reports usage accounting Caliper uses it; otherwise it falls back to a per-model price table that will drift out of date.
  • The reference agent is deterministic and rule-based. It makes no LLM calls. Its v1 and v2 differ in routing policy, which stands in for a prompt change. The measurements of it are real; the agent is a fixture.

Tests

pytest          # 273 tests, no API key required

Covers every grader including empty trajectories and zero-denominator rate metrics, runner isolation when a task raises, runner timeouts and queue-time exclusion, deterministic ordering under 1/2/8 workers, regression detection in both directions, the comparison table against a golden string, and store round-trip.

Part of a three-repo set on building and measuring agents:

  • strata - deep research agent (planner, hybrid retrieval, reflection)
  • quarry - data analyst agent (tool calls, sandboxed execution, repair loop)
  • caliper - the measurement layer, used on both of the above

caliper - 2026