Templates & test data¶
Seven suites about getting the input side of a suite right before you worry about grading. They cover the one sharp edge in Jinja templating, pulling a var's value from a file instead of writing it inline, fanning one case out across every combination of a matrix, reading — or generating — cases from something other than hand-written YAML, and giving a provider a whole conversation instead of one string. Reach for these once a suite has outgrown a handful of inline cases.
Example 06 — The !raw escape hatch¶
Every var is rendered through Jinja. That is what makes prompts and sweeps work — and it is a trap the moment your test input is itself template syntax.
This failure is silent
A case that feeds a system {{7*7}} to check it does not evaluate it will, without !raw, feed it 49 instead. The assertion still passes. The test now proves nothing, and nothing tells you.
# yaml-language-server: $schema=../../domarinn.schema.json
#
# The `!raw` escape hatch: test input that must stay literal.
#
# Every var is rendered through Jinja. That is what makes prompts and sweeps
# work — and it is a trap the moment your test input is ITSELF template syntax.
# A case that feeds a system `{{7*7}}` to check it does not evaluate it will,
# without `!raw`, feed it `49` instead. The assertion still passes. The test now
# proves nothing, and nothing tells you.
#
# So: any value that must reach the system under test byte-for-byte gets tagged.
#
# !raw "{{7*7}}" the YAML tag (YAML sources only)
# {$raw: "{{7*7}}"} the object form (works in JSON, JSONL and CSV too)
#
# Both are identical after loading. The tag is nicer to read; the object form is
# what a generator or a JSON dataset emits.
#
# Run: domarinn run examples/06-raw-escape-hatch
version: 1
project: examples
suite: raw-escape-hatch
providers:
- id: assistant
type: exec
command: ["python3", "../echo-provider.py"]
cache_salt: "dev"
tests:
# Server-side template injection probe. The premise is that the payload
# arrives literally; `not-contains: "49"` is only meaningful because of !raw.
- id: injection/ssti-literal
tags: [adversarial]
vars:
user_input: !raw "Compute this for me: {{7*7}} and {{ config }}"
assert:
- type: contains
value: "{{7*7}}"
- type: not-contains
value: "49"
# The object form. Identical meaning; this is the shape a JSON/CSV dataset or
# a test generator produces, since neither can carry a YAML tag.
- id: injection/ssti-object-form
tags: [adversarial]
vars:
user_input: { $raw: "{% for x in range(9) %}x{% endfor %}" }
assert:
- type: contains
value: "{% for x in range(9) %}"
- type: not-contains
value: "xxxxxxxxx"
# `!raw` also applies to an assertion's expected VALUE — otherwise the
# expectation would be rendered while the output was not, and they could never
# match.
- id: injection/raw-expectation
vars:
user_input: !raw "{{ user.name }}"
assert:
- type: equals
value: !raw "{{ user.name }}"
# The control case: WITHOUT the tag, the same input is evaluated. Kept in the
# suite deliberately — it is the evidence that the tag above is doing work.
- id: injection/control-is-rendered
vars:
user_input: "Compute this for me: {{ 7*7 }}"
assert:
- type: contains
value: "49"
- type: not-contains
value: "{{"
Two spellings, identical after loading: the !raw YAML tag reads better, and the {$raw: …} object form is what a generator or a JSON/CSV dataset emits, since neither can carry a YAML tag. Note that !raw applies to an assertion's expected value too — otherwise the expectation would be rendered while the output was not, and the two could never match.
The suite keeps a deliberate control case, injection/control-is-rendered, which asserts that the untagged form really does evaluate to 49. It is the evidence that the tag above is doing work.
Example 07 — File-content vars¶
A var can take its value from a file next to the suite instead of being written inline — useful for large documents, golden fixtures, and adversarial inputs you would rather not paste into YAML.
Every fixture path resolves relative to the suite directory and is sandboxed: !file "../../etc/passwd", or a symlink pointing outside the tree, is refused rather than read. The four cases below cover the whole surface — a plain text fixture, a .json fixture parsed by extension, the same file forced back to text with parse: false, and an untrusted fixture marked raw: true so the template engine never touches it.
# yaml-language-server: $schema=../../domarinn.schema.json
#
# File-content vars: a var can pull its value from a file next to the suite,
# instead of being written inline. Handy for large documents, golden fixtures,
# and untrusted adversarial inputs.
#
# Every fixture path is resolved *relative to this directory* and sandboxed —
# `!file "../../etc/passwd"` (or a symlink pointing outside) is refused, not
# read. No API key required: the system under test is ../echo-provider.py.
version: 1
project: examples
suite: file-vars
providers:
- id: echo
type: exec
command: ["python3", "../echo-provider.py"]
cache_salt: "dev"
tests:
# 1. Plain text fixture, loaded via the `!file` tag. Its content becomes the
# `user_input` var, which the echo provider echoes straight back.
- id: doc/echo
vars:
user_input: !file "fixtures/article.txt"
assert:
- type: icontains
value: "assertions"
# 2. A `.json` fixture is parsed by extension into a structured value. With no
# `user_input` set, the echo provider echoes the whole vars map, so the
# parsed object round-trips into the (valid-JSON) output.
- id: doc/structured-rubric
vars:
rubric: { $file: "fixtures/rubric.json" }
assert:
- type: is-json
- type: icontains
value: "must_mention"
# 3. `parse: false` forces text even for a structured extension — the raw JSON
# source is echoed verbatim rather than parsed.
- id: doc/raw-json-text
vars:
user_input: { $file: "fixtures/rubric.json", parse: false }
assert:
- type: contains
value: "must_mention"
# 4. An untrusted fixture marked `raw: true` is NEVER run through the template
# engine, so an SSTI payload in the file stays literal ({{7*7}} != 49).
- id: adversarial/ssti-fixture
tags: [adversarial]
vars:
user_input: { $file: "fixtures/ssti-probe.txt", raw: true }
assert:
- type: not-contains
value: "49"
raw: true is not optional for untrusted input
Every var goes through Jinja. A fixture containing {{7*7}} renders as 49 unless you mark it raw — which silently destroys the premise of any test whose whole point is that the payload stayed literal. See domarinn.yaml.
Example 08 — Matrix sweeps¶
One case fans out over the cartesian product of its axes, producing one concrete case per combination. Each axis value is merged into vars, where it wins over a base var of the same name.
The ids are deterministic — greet[style=terse,temperature=0] and friends — which is what lets domarinn diff line two runs up cell by cell. When the generated shape is unwieldy, matrix_id renders a friendlier one against the axis values.
# yaml-language-server: $schema=../../domarinn.schema.json
#
# Matrix / parameter sweeps: one case fans out over the cartesian product of its
# axes, one concrete case per combination. Each axis value is merged into `vars`
# (the axis wins over a base var of the same name).
#
# The two axes below (2 styles x 2 temperatures) expand to four cases with
# deterministic ids — stable across runs, so diffing lines cells up cell-by-cell.
# No API key required: the system under test is ../echo-provider.py, which
# (with no `user_input` var set) echoes the whole vars map — so the swept axis
# values show up directly in the output.
version: 1
project: examples
suite: matrix
providers:
- id: echo
type: exec
command: ["python3", "../echo-provider.py"]
cache_salt: "dev"
tests:
# Default ids: greet[style=terse,temperature=0], greet[style=terse,temperature=1],
# greet[style=warm,temperature=0], greet[style=warm,temperature=1].
# Each axis value lands in `vars`; the echo provider echoes the map, so every
# cell's output is valid JSON carrying its own `style`/`temperature`.
- id: greet
matrix:
style: [terse, warm]
temperature: [0, 1]
assert:
- type: is-json
- type: icontains
value: "style"
# A friendlier id shape via `matrix_id`, rendered against the axis values.
# Ids: sweep-en, sweep-fr, sweep-de.
- id: locale
matrix_id: "sweep-{{ lang }}"
matrix:
lang: [en, fr, de]
assert:
- type: icontains
value: "lang"
This expands to seven cells: a 2×2 sweep over style and temperature, plus a three-value lang sweep.
Example 09 — Datasets from files¶
Inline tests: stop scaling somewhere around thirty cases. A file:// glob keeps the suite readable and lets a dataset be owned, reviewed and diffed separately from the configuration that runs it.
# yaml-language-server: $schema=../../domarinn.schema.json
#
# Cases from files, not from this file.
#
# Inline `tests:` stop scaling somewhere around thirty cases. A `file://` glob
# keeps the suite readable and lets a dataset be owned, reviewed and diffed
# separately from the configuration that runs it.
#
# Every path is resolved relative to THIS directory and sandboxed:
# `file://../../etc/passwd` is refused, not read. Formats are chosen by
# extension — .yaml/.yml, .json, .jsonl/.ndjson, .csv, .tsv.
#
# Run: domarinn run examples/09-dataset-glob
version: 1
project: examples
suite: dataset-glob
providers:
- id: assistant
type: exec
command: ["python3", "../echo-provider.py"]
cache_salt: "dev"
defaults:
# Applied to every case from every file, so a dataset row carries only what
# makes it distinct.
assert:
- type: length
min: 1
tests:
# A glob, not a single file: adding cases/shipping.yaml adds its cases with no
# edit here. A glob that matches nothing is an error, not an empty suite.
- "file://cases/*.yaml"
A dataset file is a bare sequence of cases — the same shape an inline entry has, with no wrapper key:
# A dataset file is a bare YAML sequence of cases — the same shape an inline
# `tests:` entry has. No wrapper key.
- id: refunds/approved
tags: [billing]
vars:
user_input: "Your refund of $42.00 has been approved."
assert:
- type: icontains
value: "approved"
- id: refunds/declined-with-reason
tags: [billing]
vars:
user_input: "We could not refund this order because it is outside the 30-day window."
assert:
- type: icontains
value: "30-day"
- type: not-icontains
value: "approved"
Formats are chosen by extension: .yaml/.yml, .json, .jsonl/.ndjson, .csv, .tsv. Every path resolves relative to the suite directory and is sandboxed — file://../../etc/passwd, or a symlink pointing out of the tree, is refused rather than read. A glob that matches nothing is an error, not an empty suite.
Example 10 — A CSV dataset¶
CSV is the format a non-engineer will hand you, so domarinn reads it directly. Four column names are reserved — id, tags, cache_salt, and __assert — and every other column becomes a var of that name.
# yaml-language-server: $schema=../../domarinn.schema.json
#
# Cases from a spreadsheet.
#
# CSV is the format a non-engineer will hand you, so domarinn reads it directly.
# Four column names are reserved and mean something; every OTHER column becomes
# a var of that name.
#
# id the case id
# tags comma-separated
# cache_salt per-case cache salt
# __assert the case's assertions, as JSON
#
# `.tsv` works the same way with tabs. For anything more structured than this,
# reach for .jsonl.
#
# Run: domarinn run examples/10-dataset-csv
version: 1
project: examples
suite: dataset-csv
providers:
- id: classifier
type: exec
command: ["python3", "../echo-provider.py"]
cache_salt: "dev"
tests:
- "file://cases.csv"
id,tags,user_input,__assert
intent/refund,billing,"I want my money back","[{""type"":""icontains"",""value"":""money""}]"
intent/cancel,billing,"Please cancel my subscription","[{""type"":""icontains"",""value"":""cancel""}]"
intent/praise,csat,"This app is fantastic, thank you!","[{""type"":""not-icontains"",""value"":""cancel""}]"
__assert holds the case's assertions as JSON, which is what lets a spreadsheet express not-icontains without inventing a column convention. .tsv works identically with tabs. For anything more structured than this, reach for .jsonl.
Example 11 — Test generators¶
A glob reads cases someone wrote. A generator computes them — so coverage tracks the thing being covered instead of drifting from it. If your prompts, tools or policies live in a registry, enumerate the registry and every new entry gets a test for free, on the day it is added.
# yaml-language-server: $schema=../../domarinn.schema.json
#
# Cases produced by a program.
#
# A glob reads cases someone wrote. A GENERATOR computes them — so coverage
# tracks the thing being covered instead of drifting from it. If your prompts,
# tools, or policies live in a registry, enumerate the registry and every new
# entry gets a test for free, on the day it is added.
#
# A generator is an exec-protocol program like any other: it reads one JSON
# request on stdin and writes {"tests": [...]} on stdout. Whatever `config` you
# put here is handed to it verbatim, so one script can serve several suites.
#
# Note generators are NOT run by `domarinn list tests` unless you pass
# `--generators` — listing is meant to be free of side effects.
#
# Run: domarinn run examples/11-test-generators
version: 1
project: examples
suite: test-generators
providers:
- id: assistant
type: exec
command: ["python3", "../echo-provider.py"]
cache_salt: "dev"
tests:
- generator:
command: ["python3", "generate-cases.py"]
# Arbitrary JSON, passed straight through to the generator. Keeping the
# policy here rather than in the script means the suite still describes
# what it covers.
config:
banned_phrases: ["as an AI", "I cannot help"]
locales: ["en", "de"]
timeout_ms: 30000
A generator is an exec-protocol program like any other: it reads one JSON request on stdin and writes {"tests": [...]} on stdout. Whatever config the suite sets is handed to it verbatim, so one script can serve several suites — and, more usefully, the suite still describes what it covers instead of hiding the policy inside the script.
#!/usr/bin/env python3
"""A domarinn test generator: computes cases instead of listing them.
Reads one JSON request on stdin and writes {"tests": [...]} on stdout. Each
entry is an ordinary test — the same shape you would have written inline.
The point of a generator is that coverage cannot drift. Add a locale or a banned
phrase to the suite's `config` and the matching cases appear on the next run,
with no dataset to remember to update.
"""
import json
import sys
def main():
request = json.load(sys.stdin)
config = request.get("config") or {}
banned = config.get("banned_phrases", [])
locales = config.get("locales", [])
tests = []
# One case per banned phrase. `not-icontains` is generated, not written out
# by hand, so the list stays the single source of truth.
for phrase in banned:
slug = phrase.lower().replace(" ", "-")
tests.append(
{
"id": f"banned/{slug}",
"tags": ["policy"],
"vars": {"user_input": "Happy to help — here are your options."},
"assert": [{"type": "not-icontains", "value": phrase}],
}
)
# One case per locale.
for locale in locales:
tests.append(
{
"id": f"locale/{locale}",
"tags": ["i18n"],
"vars": {"user_input": f"locale={locale}"},
"assert": [{"type": "contains", "value": f"locale={locale}"}],
}
)
json.dump({"tests": tests}, sys.stdout)
sys.stdout.write("\n")
return 0
if __name__ == "__main__":
sys.exit(main())
Generators do not run during list
domarinn list tests deliberately does not execute generators — listing is meant to be free of side effects. Pass --generators when you want their cases enumerated too.
Example 34 — A multi-turn conversation¶
Example 02 already used a messages: prompt — system, then one user turn. This one adds what an actual back-and-forth needs: a prior ASSISTANT turn too, fixed across every case, with only the newest user turn templated per case. That is what a real follow-up question looks like — the model needs to see what it already said, not just the newest line.
# yaml-language-server: $schema=../../domarinn.schema.json
#
# A prompt with real history.
#
# `template:` renders one string. `messages:` renders a whole conversation —
# system, user, assistant, user again — which is what a follow-up question
# actually looks like: the model needs to see what it already said, not just
# the newest line. A prompt sets exactly one of the two; setting both, or
# neither, is a load error.
#
# Every message's `content` is a template like any other, so a fixed turn
# (the system persona, the canned prior answer) is free to hold no `{{ }}` at
# all — only the LAST turn below varies per case. The provider still receives
# the whole array on every call; it is domarinn that fans the case out over
# `vars`, not the shape of the messages.
#
# The exec protocol hands a messages-shaped prompt to a provider as
# `{"messages": [...]}` rather than a bare string (see
# docs/reference/protocol.md). The echo provider used here just returns
# whatever `prompt` it was given, which is exactly that object — so the
# assertions below can see the whole rendered transcript reached it.
#
# Run: domarinn run examples/34-multi-turn-conversation
version: 1
project: examples
suite: multi-turn-conversation
providers:
- id: echo
type: exec
command: ["python3", "../echo-provider.py"]
cache_salt: "dev"
prompts:
- id: support-followup
messages:
- role: system
content: "You are a support agent for Aurora Notes. Cite the policy window exactly; never invent one."
- role: user
content: "Can I return a laptop sleeve I bought 10 days ago?"
- role: assistant
content: "Yes — items are returnable within 30 days of delivery, in original condition."
- role: user
content: "{{ followup }}"
tests:
# Both cases share the fixed turns above and differ only in the follow-up,
# which is the thing under test.
- id: turns/asks-about-electronics
vars:
followup: "Does that 30-day window apply to opened electronics too?"
assert:
# The assistant's earlier turn reached the provider — proof the whole
# history, not just the newest line, is part of every call.
- type: contains
value: "30 days of delivery"
# The last turn's var rendered into the transcript the provider saw.
- type: contains
value: "opened electronics"
- id: turns/asks-about-receipts
vars:
followup: "What if I've lost the original receipt?"
assert:
- type: contains
value: "30 days of delivery"
- type: contains
value: "lost the original receipt"
# Proof this case rendered its OWN follow-up rather than the other
# case's: each case gets a fresh render, never a leftover from one that
# ran before it.
- type: not-contains
value: "opened electronics"
Every turn is a template, so a fixed turn simply has nothing in it to substitute — only the last one varies per case. Both cases above share the first three turns byte for byte and differ only in the follow-up, which is the thing under test.
The echo provider makes this observable: an exec provider receives a messages: prompt as {"messages": [...]} (see the protocol), and echoing it back is what lets a contains assertion prove the whole rendered history — not just the last var — actually reached the provider.
Example 41 — Per-case conversation history¶
Example 34 fixes the transcript in the prompt: every case shares the same prior turns. This one moves the history to the cases — each brings its own prior turns, different lengths and roles included, and the prompt names where they splice in with the bare history marker entry:
# yaml-language-server: $schema=../../domarinn.schema.json
#
# Per-case conversation history.
#
# Example 34 fixes the transcript in the prompt: every case shares the same
# prior turns and varies only the newest line. This example moves the history
# to the CASES: each one brings its own prior turns — different lengths,
# different roles — and the prompt names where they splice in with the bare
# `history` marker entry. A prompt may hold at most one marker; a prompt
# without one gets each case's history right after its leading system
# turn(s), and a `template:` prompt becomes the transcript's newest user
# turn.
#
# A case writes its history inline, or points at a whole transcript with
# `history: file://…` (a YAML/JSON list of `{role, content}`). A CSV test
# file carries it in the reserved `__history` column — a JSON list of turns
# or a `file://` path, with an empty cell meaning none. Turn contents are
# templates like any other message content, and may be `file://` themselves.
#
# History is part of each case's request identity: two cases that differ only
# in their prior turns key — and cache — separately.
#
# The echo provider returns the prompt it was handed, so the assertions below
# can see exactly which transcript reached the provider — and, just as
# important, which other case's transcript did NOT.
#
# Run: domarinn run examples/41-per-case-history
version: 1
project: examples
suite: per-case-history
providers:
- id: echo
type: exec
command: ["python3", "../echo-provider.py"]
cache_salt: "dev"
prompts:
- id: support
messages:
- role: system
content: "You are a support agent for Aurora Notes. Answer from the conversation so far."
- history
- role: user
content: "{{ followup }}"
tests:
# No history: the marker simply disappears and the case is a first contact.
- id: history/first-contact
vars:
followup: "What is your return window?"
assert:
- type: contains
value: "What is your return window?"
# Nothing from the other cases' conversations leaked in.
- type: not-contains
value: "carrying case"
# Two prior turns, inline. This case's transcript is system → user →
# assistant → user, and only this case sees these turns.
- id: history/follow-up
history:
- role: user
content: "Can I return a carrying case bought 10 days ago?"
- role: assistant
content: "Yes — within 30 days of delivery, in original condition."
vars:
followup: "And if the box is already open?"
assert:
# The assistant's earlier turn reached the provider…
- type: contains
value: "30 days of delivery"
# …together with this case's newest line.
- type: contains
value: "already open"
# A whole transcript from a file. Longer conversations live next to the
# suite instead of bloating it; the file is a YAML list of turns.
- id: history/escalation
history: file://convos/escalation.yaml
vars:
followup: "So who exactly is going to call me back?"
assert:
- type: contains
value: "escalate this to a supervisor"
- type: not-contains
value: "carrying case"
# CSV rows: `__history` is reserved, like `__assert`.
- file://cases.csv
The history/escalation case points at a transcript file instead of inlining it — the same shape, a YAML list of turns:
# A transcript a case points at with `history: file://convos/escalation.yaml`.
# The same shape as inline history: a list of {role, content} turns.
- role: user
content: "My order arrived damaged and the support chat disconnected twice."
- role: assistant
content: "I'm sorry — I can escalate this to a supervisor right away."
- role: user
content: "Please do."
- role: assistant
content: "Done. You'll be contacted within one business day."
And the CSV rows carry theirs in the reserved __history column, JSON-encoded (or a file:// path). An empty cell means unset — with a defaults.history in play it would inherit the default; a literal [] cell is the opt-out:
id,__history,followup,__assert
csv/with-history,"[{""role"": ""user"", ""content"": ""Do you ship to Iceland?""}, {""role"": ""assistant"", ""content"": ""We do — flat rate, 5 business days.""}]",How much is the flat rate?,"[{""type"": ""contains"", ""value"": ""5 business days""}, {""type"": ""contains"", ""value"": ""How much is the flat rate?""}]"
csv/no-history,,Do you ship to Iceland?,"[{""type"": ""contains"", ""value"": ""Do you ship to Iceland?""}, {""type"": ""not-contains"", ""value"": ""flat rate""}]"
Three details worth knowing. A prompt may hold at most one marker, and a prompt without one still works: each case's history lands right after the prompt's leading system turn(s), and a template: prompt becomes the transcript's newest user turn. Second, history joins each case's request identity — two cases differing only in their prior turns key, and cache, separately. Third, the not-contains assertions above are the point: each case's transcript is its own, and nothing from another case's conversation leaks in.
See per-case history in the reference for the full splice rules.
Example 42 — Replaying a tool-using transcript¶
Example 41 gives each case its own prior turns, but only as prose — and that is the wrong shape for the case history is most useful for. An agent told to "call lookup_order first" will, in a single-turn eval, call it and stop: nothing ever feeds a result back, so the decision you actually wanted to grade never happens. History is what supplies turn one's result. But turn one is a tool call, and turn two's input is a tool result:
# yaml-language-server: $schema=../../domarinn.schema.json
#
# Replaying a tool-using transcript.
#
# Example 41 gives each case its own prior turns, but only as prose. That is
# the wrong shape for the case `history` is most useful for: an agent told to
# "call `lookup_order` first" will, in a single-turn eval, call it and stop —
# nothing ever feeds a result back, so the decision you actually want to grade
# never happens. History is what supplies turn one's result. But turn one IS a
# tool call, and turn two's input IS a tool result.
#
# So a turn carries two more things:
#
# * an `assistant` turn may have `tool_calls` — the same {id, name,
# arguments} shape a provider REPORTS on the way out, so the `tool_calls`
# block of a stored case pastes straight into a suite;
# * a `tool` turn carries the result, naming the call it answers with
# `tool_call_id` (optional — position pairs them when it is omitted).
#
# Each provider maps these to its own shape, and they disagree in exactly
# mirrored ways: `anthropic` coalesces a round of parallel results into ONE
# user message of `tool_result` blocks, while `openai` expands the same round
# into separate `role: "tool"` messages. `exec` receives the JSON verbatim.
#
# A turn's `content` may also be a list of typed blocks rather than a string,
# which is how a model's `thinking` is replayed. Thinking is NEVER templated:
# its `signature` is a vendor integrity token over those exact bytes, so
# rendering a variable inside one would invalidate the replay.
#
# domarinn still never EXECUTES a tool — it replays what happened and grades
# what the model does next.
#
# The echo provider returns the prompt it was handed, so the assertions below
# can see exactly which structured turn reached the provider — and that it
# arrived as structure rather than as a paraphrase of one.
#
# Run: domarinn run examples/42-tool-call-history
version: 1
project: examples
suite: tool-call-history
providers:
- id: echo
type: exec
command: ["python3", "../echo-provider.py"]
cache_salt: "dev"
# The declared surface, so a reader sees the tool the transcript replays a
# call into. Names must match: a call is not templated, for the same reason
# this declaration is not.
tools:
- name: lookup_order
description: Fetch an order by its id.
input_schema:
type: object
required: ["order_id"]
properties:
order_id: { type: integer }
# Declared because the file-based transcript below replays a call into it —
# the same "names must match" rule, applied to a transcript loaded from disk.
- name: refund_policy
description: Look up the return window for a product category.
input_schema:
type: object
required: ["category"]
properties:
category: { type: string }
prompts:
- id: support
messages:
- role: system
content: "You are a support agent. Use the tools you are given."
- history
- role: user
content: "{{ followup }}"
tests:
# One round trip: the model called a tool, the tool answered, and the case
# grades what it says next. `tool_call_id` names which call the result
# answers — omit it and position pairs them instead.
- id: tools/replays-a-call
history:
- role: user
content: "Where is order 1042?"
- role: assistant
tool_calls:
- id: call_1
name: lookup_order
arguments: { order_id: 1042 }
- role: tool
tool_call_id: call_1
content: '{"status": "shipped", "eta": "Tuesday"}'
vars:
followup: "So when does it arrive?"
assert:
# The call reached the provider as a structured turn…
- type: contains
value: "lookup_order"
# …and so did the result it answers.
- type: contains
value: "shipped"
# A parallel round: one assistant turn, two calls, two results. This is the
# case that makes the vendor split concrete — `anthropic` folds both results
# into one user message, `openai` sends two `role: "tool"` messages, and the
# suite says neither.
- id: tools/parallel-round
history:
- role: user
content: "Status of 1042 and 1043?"
- role: assistant
content: "Let me look up both."
tool_calls:
- name: lookup_order
arguments: { order_id: 1042 }
- name: lookup_order
arguments: { order_id: 1043 }
- role: tool
content: '{"status": "shipped"}'
- role: tool
content: '{"status": "pending"}'
vars:
followup: "Which one is late?"
assert:
- type: contains
value: "pending"
# No id was written anywhere; domarinn derived one from position so both
# vendors still get the correlation they require.
- type: contains
value: "Let me look up both."
# Arguments are templated leaf by leaf, and every leaf keeps its own type:
# an untemplated `1042` stays the integer the tool's schema wants, while the
# string leaf beside it is rendered against the case vars.
#
# The corollary is worth stating, because it is easy to trip over: a leaf you
# template is a STRING on the way out. `{order_id: "{{ order }}"}` sends
# `"7"`, not `7` — and so does a whole-value `arguments: "{{ some_var }}"`,
# since templating is string-in/string-out at every leaf. A non-string
# argument therefore has to be written literally, as `order_id` is below.
- id: tools/templated-arguments
history:
- role: user
content: "Where is order {{ order }} for {{ who }}?"
- role: assistant
tool_calls:
- name: lookup_order
arguments: { order_id: 1042, note: "for {{ who }}" }
- role: tool
content: '{"status": "delivered"}'
vars:
order: 7
who: "Ada"
followup: "Thanks — was it signed for?"
assert:
# The string leaf rendered against the case vars…
- type: contains
value: "for Ada"
# …while the untemplated number stayed a number: JSON writes an integer
# bare, so a quoted "1042" here would mean the type was lost.
- type: contains
value: '"order_id":1042'
- type: not-contains
value: '"order_id":"1042"'
# A `content` list instead of a string: this is how a model's reasoning is
# replayed. The `signature` is an integrity token over the thinking bytes, so
# neither it nor the text it covers is ever templated.
- id: tools/replays-thinking
history:
- role: user
content: "Where is order 1042?"
- role: assistant
content:
- type: thinking
thinking: "The customer gave an order id, so lookup_order applies."
signature: "sig-abc123"
- type: text
text: "Checking that now."
tool_calls:
- name: lookup_order
arguments: { order_id: 1042 }
- role: tool
content: '{"status": "shipped"}'
vars:
followup: "And the carrier?"
assert:
# The signature survived verbatim — proof the block was not re-rendered.
- type: contains
value: "sig-abc123"
- type: contains
value: "Checking that now."
# A whole tool-using transcript from a file, so a long agent trace lives next
# to the suite instead of inside it.
- id: tools/from-a-file
history: file://convos/lookup.yaml
vars:
followup: "Can you refund it?"
assert:
- type: contains
value: "refund_policy"
- type: not-contains
value: "1042"
An assistant turn's tool_calls use the same {id, name, arguments} shape a provider reports on the way out, so the tool_calls block of a stored case pastes straight into a suite. A tool turn carries the result, naming the call it answers with tool_call_id — optional, because position pairs them when a transcript omits it, which is what makes the tools/parallel-round case above work without a single id written anywhere.
A whole tool-using trace can live in a file, which is usually where one captured from a real agent run already is:
# A tool-using transcript a case points at with
# `history: file://convos/lookup.yaml`.
#
# The same shape as inline history, tool turns included — which is the point:
# a trace captured from a real agent run is a file, not something you want
# pasted into a suite.
- role: user
content: "This jacket does not fit. What are my options?"
- role: assistant
content: "Let me check what applies to your order."
tool_calls:
- id: call_policy
name: refund_policy
arguments:
category: apparel
- role: tool
tool_call_id: call_policy
content: '{"window_days": 30, "condition": "unworn, tags attached"}'
- role: assistant
content: "You have 30 days from delivery, provided the tags are still attached."
Three details. Arguments are templated leaf by leaf, so each leaf keeps its own type: an untemplated {order_id: 1042} stays an integer, which is what a tool-call assertion comparing against the decoded object needs. The corollary is easy to trip over — a leaf you do template comes out a string, including a whole-value "{{ var }}" reference — so a non-string argument has to be written literally, and varying one per case is not expressible today. A turn's content may be a list of typed blocks instead of a string, which is how thinking is replayed; that one is never templated, because its signature is a vendor integrity token over those exact bytes. And the two vendors disagree in mirrored ways about a round of parallel results — anthropic folds them into one user message, openai sends one message each — which domarinn handles so the suite does not have to say.