Evals

Agent evals answer the question tests can’t: given these model decisions, does the durable machinery do the right thing? A scenario scripts the model’s decisions step by step; everything else is real — the real registry resolves your real tools, the real Ledger enforces effect modes and approvals, and the assertions read the genuine durable transcript, not a mock.

They run keyless and deterministically, which makes them a deploy gate:

bin/rails silas:eval     # loads <eval_dir>/**/*_eval.rb; exits 1 on failure

The installer wires this into bin/ci (or tells you to add it to yours), and the application template generates a working example in test/agent_evals/refund_desk_eval.rb.

A scenario

Silas::Eval.scenario "over the gate: a £64 refund holds at the signal" do
  input "The walnut monitor stand (order R-1002) arrived cracked."

  on_step 0, call: { name: "lookup_order", arguments: { number: "R-1002" } }
  on_step 1, call: { name: "issue_refund",
                     arguments: { number: "R-1002", amount_pence: 6400, reason: "arrived cracked" } }

  expect do
    assert_parked tool: "issue_refund"   # the turn holds at zero compute
    assert_no_tool_called "issue_refund" # and the refund row does NOT exist
  end
end

on_step index, ... is the script: what the “model” returns on that model call. text: is an assistant text block, call:/calls: are tool calls. A step index with no entry ends the scripted conversation.

The DSL

Call Meaning
input "…" The turn’s input. Required.
on_step i, text:, call:, calls:, data: The scripted model output for step i. data: scripts a structured answer (the final_answer schema case) — it becomes the same block the real adapter persists, so assert_answer_data reads it back.
approve tool: "name" When the turn parks on this tool, approve it (recorded as approved by "eval" — the same approve! the inbox uses) and resume. This is how you assert exactly-once across a hold.
stub_tool "name", effect_mode:, approval: { \|**args\| … } Replace a real tool with a stub for a side-effect-free scenario. Unstubbed tools stay real.
mode :real Drive a real model instead of the script. Skipped automatically when ANTHROPIC_API_KEY is unset, so the gate stays green offline.
max_steps n Override the per-turn step cap for this scenario.
metadata h / tags on scenario "…", tags: [...] Stamped on the session / used for filtering.
expect { … } The assertion block. Required. Runs after the turn reaches its resting state.

Assertions

All assertions read only the durable rows, and they collect failures — one run reports every miss, not just the first.

Assertion Checks
assert_tool_called "name", times: 1 The tool actually executed (times: for exactly-N — the exactly-once assertion).
assert_no_tool_called "name" No execution happened (e.g. because the turn parked first).
assert_tool_arg "name", :key, value (or a block predicate) An argument the model passed.
assert_parked tool: "name" The turn is waiting, held on that tool’s approval (or in-doubt).
assert_approved tool: "name" The invocation’s approval state is approved.
assert_turn_completed / assert_turn_failed(reason: "…") Terminal state.
assert_final_matches(/…/) The final answer text.
assert_answer_data(key: :verdict, value: "approve") The structured final_answer payload (whole-Hash and predicate forms too).
assert_no_hallucinated_price(allowed: []) Every money amount in the final answer traces to a number the agent actually saw (tool results or the input), allowing pence↔pounds scaling.
assert_rubric "…" LLM-graded check against config.eval_graderskips offline rather than failing the gate (set SILAS_EVAL_STRICT=1 to make skips fail).

How a scenario runs (so you can trust it)

The driver starts a real session (Silas.agent.start), runs the real AgentLoopJob inline on the :test queue adapter, and — for each approve tool: — performs the same approve! an operator would, then resumes the loop. Scripted mode swaps only the inference adapter; mode :real uses your configured one.

Two consequences worth knowing:

  • Rows persist. Scenarios commit ordinary durable rows to the database of the environment you run them in (they need real seeds for tools that read your tables — the template’s CI runs db:seed silas:eval). Run against a scratch database if residue bothers you.
  • Config is restored per run, but scenarios execute sequentially in one process — don’t have two scenarios fight over global state you set yourself.

Configuration

Silas.configure do |c|
  c.eval_dir = "test/agent_evals"   # where *_eval.rb files live
  # c.eval_grader = ->(prompt) { … } # custom LLM grader for assert_rubric
end

This site uses Just the Docs, a documentation theme for Jekyll.