Wind Tunnel — unittest for agents. A controlled replica of production conditions for agent reliability testing.
Project description
Wind Tunnel
unittest for agents. A reliability bench for tool-using LLM agents — structured, diff-able, and runnable in CI.
You don't fly a new airframe straight into a storm; you put it in a wind tunnel. Wind Tunnel is the same idea for agents: a controlled replica of production conditions where you watch how the agent behaves before you deploy it.
from windtunnel.api import Scenario, run_scenario
from windtunnel.mcp.fastmcp import FastMCPServer, LoggingFastMCP
crm = LoggingFastMCP("crm") # the scenario brings its own tools
@crm.tool()
def client_lookup(query: str) -> dict:
return {"name": "Bluewing Logistics", "email": "ops@bluewing.example"}
scenario = Scenario(
name="lookup_before_answer",
prompt="What is the email on file for the client Bluewing Logistics?",
target_facts=[["ops@bluewing.example"]], # AND-of-OR groups
requires_tool_use=True, # guessing = failing
must_call=["client_lookup"], # trajectory expectation
)
result = run_scenario(scenario, runtime=MyPlatformRuntime(), # your SPI driver
mcps=[FastMCPServer(mcp_instance=crm)]) # runner starts/stops it
print(result.aggregate.verdict) # PASS / FAIL
MyPlatformRuntime is the part you bring — four small methods that wire
Wind Tunnel to your agent platform (writing a runtime).
No platform wired up yet? An in-memory stub runtime ships for learning the
scoring model first — start with getting started.
Why this exists
Agents fail differently than functions. The answer can be right while the path was wrong (it guessed instead of calling the tool). The path can be right while the answer is wrong. A model can pass at temperature 0 and fall apart at 0.7. It can handle a clean conversation and break the moment one corrupted turn appears in its history.
Conventional evals score the final answer. Wind Tunnel scores the whole flight across three independent behavior layers, then separately verifies that the experiment itself was valid:
| Layer | Question |
|---|---|
| outcome | Was the user-visible answer right? |
| trajectory | Were the right tools called, in the right order, none forbidden? |
| constraint | Did named policy predicates over the trace hold? |
| integrity | Were the declared perturbations actually applied? |
By default, the deploy gate includes outcome plus every trajectory and
constraint expectation the scenario declares. An author can set
gate_layers explicitly when a layer is intentionally diagnostic. Integrity
is never optional: if a declared perturbation did not apply, the run is
INVALID, not an agent pass or failure.
Robustness is the behavior of the agent under adverse conditions. Reports therefore calculate robustness from gate performance on scenarios that actually declare perturbations; it is no longer a marker-presence score.
A batch of N runs aggregates to a verdict: PASS only if all N runs
pass (or PASS_WITH_VARIANCE for scenarios that explicitly allow sampler
variance). Every scenario carries a FailureCost annotation. Its stable risk
weight combines severity, customer visibility, reversibility, and performed
side effects; reports and comparisons use that weight to rank regressions
without weakening the fail-closed verdict.
How is this different from Inspect, promptfoo, or a hand-rolled pytest harness? The founding bet: agent reliability bugs live in the seams — chat templates, tool-schema sanitizers, message-history plumbing, session state — not in the model alone. So instead of scoring your agent inside a lookalike harness, Wind Tunnel's SPI is built to drive your real production path (same images, same proxies, same tool mounting) and score what comes back. And it doesn't take the transcript's word for anything: when a logging mock is in play, tool traffic is recorded at the tool server itself and trajectory scoring asserts what the agent actually did, not what it claimed to do — falling back to the transcript only when no server log exists.
The dimensions
Scenarios are organized into reliability dimensions — each one isolates a property that tool-using agents are known to fumble:
| Dimension | Property tested |
|---|---|
tool_affordance |
Builds the right mental model of each tool's contract |
clarify_vs_guess |
Clarifies under genuine ambiguity instead of silently guessing |
memory_conflict |
Trusts live tool results over stale memory |
multi_turn_drift |
Preserves context across a multi-turn session |
policy_pressure |
Holds a policy when the user pushes to skip it |
recovery |
Recovers from a bad intermediate state injected into history |
sampler_sensitivity |
Stays correct across the temperature/top_p matrix |
side_effect_safety |
Respects autonomy ceilings per effect class (read < send < destroy) |
silent_failure |
Notices the environment misbehaving instead of fabricating |
icl_poisoning |
Survives a corrupted prior turn without copying the broken shape |
Perturbations: adversarial weather
Two families, distinguished by where the corruption lands:
- Pre-send / history-shaping — inject corrupted prior turns into the messages the live model actually runs on (a blank assistant turn, a malformed tool call, a stale memory line). The model either resists the poison or succumbs; the eval is real either way.
- Environment-shaping — make the mock tool server misbehave live: malformed JSON, timeouts, unexpectedly empty results.
Every perturbation declares a marker, and the integrity check verifies the contract was honoured — a perturbation that silently failed to apply can't produce a false pass. Once the condition is known to be valid, that scenario's ordinary gate result contributes to the robustness pass rate.
Bring your own platform: the API/SPI split
- API (
windtunnel/api/) — what scenario authors import:Scenario,Trace,Score, evaluators, perturbations,run_scenario(). Backend-agnostic. - SPI (
windtunnel/spi/) — four small Protocols runtime implementers fill in:AgentRuntime,AgentHandle,MCPServer,MCPHandle. Implement them for your agent platform and every scenario runs against it unchanged.
Hard invariant: a scenario never imports a platform-specific type. This is enforced in CI by an import-invariant test, not by convention.
The unit of record: traces
Every run produces a Trace — JSON, diff-able, replayable. Reports,
comparisons, and triage all consume saved traces; you never re-run a model
to re-analyze a run.
A real (sanitized) failing trace from the bench
This run injected a schema-rejected tool call into history
(recovery dimension) and the agent fabricated an order table —
note the tool call with empty arguments next to a confident, fully
populated answer. The outcome layer fails it; triage classifies it as
fabrication-after-tool-failure.
{
"scenario_id": "bad_arg_then_retry",
"agent_id": "wt-cli",
"variant_id": "validate_prod_config",
"turns": [
{
"role": "user",
"content": "Show me all orders at the intake stage for client ACC-BLWG-001. I need quantity by work queue."
},
{
"role": "assistant",
"content": "Here are all orders at the **Intake** stage for client **ACC-BLWG-001** (Bluewing Logistics), grouped by work queue:\n\n| Order ID | SKU | Item Name | Work Queue | Quantity |\n|----------|-----|-----------|------------|----------|\n| ORD-1001 | B001AAA | Bluewing Jersey - Home | Q-INTAKE-1 | 12 |\n| ORD-1002 | B002BBB | Bluewing Cap - Adjustable | Q-INTAKE-2 | 8 |",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": { "name": "ops_order_query", "arguments": "" }
}
],
"latency_ms": 19728.3
}
],
"tool_schema_hash": "sha256:9e7ab358…",
"worker_warnings": [
"perturbation_applied: inject_schema_rejected_call turn_idx=0 tool=ops_order_query"
]
}
Trace import workflow
Wind Tunnel's core loop is: export a production-shaped trace as a Contract A
*.wtin.json envelope, validate it, import it, author the gate, and run it as
a regression:
wt validate --strict incident.wtin.json
wt import --trace incident.wtin.json --out scenarios/imported/incident/
# review scenario.py, scorer.py, fixture.universe.json, and IMPORTED.md
wt run --runtime <your-runtime> --scenario incident --runs 3
wt import is a skeleton generator, not an oracle. The generated scenario
intentionally fails until a human replaces its placeholder outcome gate.
Install
pip install windtunnel-bench # installs `import windtunnel` + the `wt` CLI
(The distribution is windtunnel-bench; the import name is plain
windtunnel — the bare PyPI name is a squatted empty registration with a
PEP 541 transfer request pending.)
Working on Wind Tunnel itself? See CONTRIBUTING.md for the dev setup
(uv venv + editable install + the unit suite).
CLI
wt run --scenario lookup_before_action --runtime <your-runtime> --runs 3
wt report --runs runs/ --format html --out report.html
wt compare --labels baseline candidate
wt replay --trace runs/<trace>.json --runtime in_memory
wt doctor --runtime http_inject
wt validate --strict incident.wtin.json
wt import --trace incident.wtin.json --out scenarios/imported/incident/
wt triage --runs runs/ --classifier rule_based
wt run can also emit CI artifacts with --format junit|json --out FILE.
The built-in runtimes are in_memory and http_inject; runtime plugins are
discovered from the windtunnel.runtimes entry-point group or a module:attr
dotted path.
Documentation
- Getting started — install, first scenario, first report
- CLI reference — every shipped
wtcommand and option - Architecture — the two-surface design, gates, integrity, and robustness model
- Migrating to 0.9 — intentional scoring and artifact changes
- Writing a scenario — the
Scenarioschema, field by field - Writing a runtime — implement the SPI for your platform
- Importing a trace — turn a Contract A trace into a regression skeleton
- Recording a universe — serve recorded tool calls as a hermetic upstream
- Agent quickstart — using a coding agent? Point it at this one file to integrate Wind Tunnel into your repo
- Failure taxonomy — classification categories and fix vectors
- Writing a classifier · Writing an optimizer
Status
Wind Tunnel is extracted from a production bench used to gate agent deploys on a live multi-agent platform (local models, MCP tools, a chat gateway). The API is young — expect breaking changes before 1.0.
Wind Tunnel also ships version-matched agent instructions (wt skill install) — and benches them: examples/skill-eval/
runs a terminal agent against tasks from these docs with and without them
in the workspace, scored by Wind Tunnel itself. First live results are in
that directory's README; the short version is that documentation bought
knowing when to stop more than knowing what to type.
License
Apache-2.0 — see LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file windtunnel_bench-0.9.0.tar.gz.
File metadata
- Download URL: windtunnel_bench-0.9.0.tar.gz
- Upload date:
- Size: 862.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e822937b88e2c03df4f8d135f1652dc6d4515f7407f72bfd3537d48361e6eb12
|
|
| MD5 |
d4d179c6b0f9a4749f942753cc08f441
|
|
| BLAKE2b-256 |
b694ebbbb070a81b13040caba5fa9eb5eaf33d8a0c968bd9d7a594e532eb7134
|
Provenance
The following attestation bundles were made for windtunnel_bench-0.9.0.tar.gz:
Publisher:
release-please.yml on syntropy-systems-oss/windtunnel
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
windtunnel_bench-0.9.0.tar.gz -
Subject digest:
e822937b88e2c03df4f8d135f1652dc6d4515f7407f72bfd3537d48361e6eb12 - Sigstore transparency entry: 2136350000
- Sigstore integration time:
-
Permalink:
syntropy-systems-oss/windtunnel@ee3dd566f14dee12a2d095d9b94c55e11c76bf4f -
Branch / Tag:
refs/heads/main - Owner: https://github.com/syntropy-systems-oss
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release-please.yml@ee3dd566f14dee12a2d095d9b94c55e11c76bf4f -
Trigger Event:
push
-
Statement type:
File details
Details for the file windtunnel_bench-0.9.0-py3-none-any.whl.
File metadata
- Download URL: windtunnel_bench-0.9.0-py3-none-any.whl
- Upload date:
- Size: 419.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
33bac84f9740a536ef59d5441bc9a28b121e32c39417d84f97147d5e05ee3a02
|
|
| MD5 |
8938d146a8686460baeccb44765607c3
|
|
| BLAKE2b-256 |
728013624b236af3f070d9f50893deea97e372db0cee6c35fde59d8c57adaa77
|
Provenance
The following attestation bundles were made for windtunnel_bench-0.9.0-py3-none-any.whl:
Publisher:
release-please.yml on syntropy-systems-oss/windtunnel
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
windtunnel_bench-0.9.0-py3-none-any.whl -
Subject digest:
33bac84f9740a536ef59d5441bc9a28b121e32c39417d84f97147d5e05ee3a02 - Sigstore transparency entry: 2136350064
- Sigstore integration time:
-
Permalink:
syntropy-systems-oss/windtunnel@ee3dd566f14dee12a2d095d9b94c55e11c76bf4f -
Branch / Tag:
refs/heads/main - Owner: https://github.com/syntropy-systems-oss
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release-please.yml@ee3dd566f14dee12a2d095d9b94c55e11c76bf4f -
Trigger Event:
push
-
Statement type: