Testing agent skills¶
Agent skills usually ship as a folder: the instructions the model follows
(SKILL.md) next to a tests.json of acceptance cases:
skills/german-b2b-outreach-email/
├── SKILL.md # the prompt/instructions the agent loads
└── tests.json # acceptance cases for the skill
{
"cases": [
{
"input": "Draft a first-contact email to a procurement director at a mid-sized German pump manufacturer ...",
"expected": {
"contains": ["Betreff:", "Mit freundlichen Grüßen", "P.S."]
}
}
]
}
contains checks are a good start, but they only catch the failures you
predicted. Edit SKILL.md, switch the underlying model, and the output can
keep every required substring while the tone, structure, or substance quietly
changes. That's exactly the gap BehaviorCI closes.
from_skill_tests()¶
from_skill_tests() maps each case onto an ordinary @behavior
test:
# test_outreach_skill.py
from behaviorci.skills import from_skill_tests
from myagent import run_with_skill
def run_skill(prompt: str) -> str:
# However you invoke your agent with the skill loaded:
# a CLI call, an SDK call, an HTTP request, ...
return run_with_skill("skills/german-b2b-outreach-email", prompt)
globals().update(
from_skill_tests("skills/german-b2b-outreach-email", run=run_skill, threshold=0.80)
)
The globals().update(...) line injects one generated test per case into the
module, so pytest collects them like hand-written tests:
test_outreach_skill.py::test_german_b2b_outreach_email_case_0 PASSED
test_outreach_skill.py::test_german_b2b_outreach_email_case_1 PASSED
test_outreach_skill.py::test_german_b2b_outreach_email_case_2 PASSED
Each case maps like this:
| tests.json | BehaviorCI |
|---|---|
cases[n].input |
The prompt passed to your run callable |
cases[n].expected.contains |
must_contain — Layer 0 lexical guardrail |
| The returned output text | Snapshotted — Layer 1 semantic similarity |
| Folder name + case index | behavior_id, e.g. german-b2b-outreach-email/case-0 |
The usual loop applies unchanged:
$ pytest --behaviorci-record # record baselines per case
$ pytest --behaviorci # fail CI on skill regressions
$ pytest --behaviorci-update # accept an intentional SKILL.md change
What a failure looks like¶
Suppose an edit to SKILL.md makes the email pushy while all required
substrings survive. The contains check passes — the snapshot doesn't:
FAILED test_outreach_skill.py::test_german_b2b_outreach_email_case_2
BehaviorCI: Similarity 0.6421 < threshold 0.8000
--- STORED OUTPUT (Primary Sample) ---
Betreff: DSGVO-konforme Account-Priorisierung — kurzer Austausch?
... would a 20-minute call next week work for you? ...
--- CURRENT OUTPUT (Primary Sample) ---
Betreff: Letzte Chance — Angebot läuft ab!
... buy now, slots are almost gone ...
Options¶
from_skill_tests(
skill_dir, # folder containing tests.json (str or Path)
run, # Callable[[str], str] — your skill runner
threshold=0.85, # similarity threshold applied to every case
id_prefix=None, # override the folder name in behavior ids
)
- Behavior ids are
<skill-name>/case-<n>, so several skills coexist in one suite without colliding. Useid_prefixif two skill folders share a name. - A case without an
expected.containslist still works — it's snapshot-only. - A malformed
tests.json(missing file, invalid JSON, emptycases, a case without a stringinput) raises aConfigurationErrorat collection time with the exact case index.
Keeping the runner honest¶
The generated tests are only as meaningful as the run callable. Two rules of
thumb:
- Pin what you can. Fix the model version and temperature in the runner so
the snapshot tracks skill changes, not sampling noise. For skills that
stay noisy anyway, lower the
threshold— or write the noisiest case by hand withsamples=N. - Return only the artifact. If your agent wraps the output in logs or markdown fences, strip them in the runner so the baseline is the email (or JSON, or SQL) itself.
A complete runnable example — skill folder, canned runner, record/check flow —
lives in the repository under
tests/examples/.