Pytest plugin & chaos contracts (0.6.0)
v0.6 makes chaos part of an ordinary test run. A @pytest.mark.chaos test
runs inside a chaos session when pytest is started with --chaos, and its
contracts — pass/fail resilience checks — decide whether it passes.
Without --chaos the marker does nothing and the test runs normally.
The plugin and the contracts ship in the agentic-chaos distribution. The
package stays standalone: no sibling package is required, and pytest is only
needed if you use the plugin (pip install "agentic-chaos[pytest]").
Quick start
import pytest
from agentic_chaos.contracts import MaxRetries, NoRetryStorm
@pytest.mark.chaos(faults=["rate_limit_storm"], must_recover=True)
def test_agent_handles_rate_limits():
result = my_agent("What's the weather?")
assert result is not None
@pytest.mark.chaos("rate_limit_storm", contracts=[MaxRetries(3), NoRetryStorm(5)])
def test_retry_budget_is_bounded():
assert my_agent("What's the weather?")
pytest # markers inert: tests run normally
pytest --chaos # faults injected, contracts checked
pytest --chaos --chaos-report-dir chaos-reports --chaos-contract no_retry_storm:10
The code under test must be instrumented the usual way: chaos_call(),
chaos_acall(), chaos_stream(), wrap_tool()/wrap_node() or a provider
patch. Outside a session these are plain calls, so the same code runs in
production and in the non-chaos test run.
Marker reference
@pytest.mark.chaos(faults=None, *, profile=None, profile_file="chaos.toml",
contracts=(), must_recover=False, baseline=False, breaker=None, dry_run=False)
| Argument | Meaning |
|---|---|
faults |
Fault names (list or comma-separated string) or fault instances. May be the only positional argument. Instances are deep-copied per run, so burst counters and RNG state never leak between tests or parametrized cases. |
profile, profile_file |
A named chaos.toml profile instead of faults (exactly one of the two is required). Relative paths resolve against the working directory. |
contracts |
Contract instances or spec strings such as "max_retries:3", or a single instance. |
must_recover |
Adds RecoveredAfterFailure() unless one is already listed. |
baseline |
Run the test once as a dry run first (same fault selection, nothing injected), then under chaos. The dry run is the baseline for MaxCostImpact and FullTopologyTraversal. A failing baseline fails the test with a clear message. |
breaker |
A CircuitBreaker for the session (see the safety guide). |
dry_run |
Preview only: target calls run unchanged and planned faults are recorded. Cannot be combined with baseline=True. |
Invalid arguments (unknown fault or contract, both or neither of
faults/profile, a missing profile file) fail that test with
invalid @pytest.mark.chaos: ... before it runs.
A contract that fails or is inconclusive fails the test with a
ContractViolationError (an AssertionError) whose message lists every
result. If the test body itself raised, that error is re-raised and the
contract results appear in a "chaos contracts" report section. pytest.skip,
pytest.xfail and pytest.exit pass straight through; they produce no
chaos result.
Options
| Option | ini | Meaning |
|---|---|---|
--chaos |
chaos = true |
Activate chaos markers. |
--chaos-report-dir DIR |
chaos_report_dir |
Write <stem>.chaos.json (the native ChaosReport), <stem>.baseline.json and <stem>.contracts.json per chaos test. <stem> is the sanitized node ID plus a short hash of the full ID, so names never collide. |
--chaos-contract SPEC |
— | Add a contract to every chaos test. Repeatable. |
The terminal summary prints an agentic-chaos section with the number of
chaos tests, fault events and contract passes, and lists every failing
result. Disable the plugin entirely with -p no:agentic_chaos.
The chaos fixture
@pytest.mark.chaos(faults=["tool_failure"], baseline=True,
contracts=[FullTopologyTraversal()])
def test_every_node_runs(chaos):
tracker = chaos.attach_topology(TopologyTracker(auto_activate=False))
run_graph(tracker)
chaos.record_cost(0.002) # feeds MaxCostImpact(metric="cost_usd")
assert chaos.session.events # the live ChaosSession
chaos.session is the active session (the dry-run one during a baseline run),
chaos.enabled/chaos.active say whether chaos is on, and record_cost(),
record_usage() and attach_topology() are no-ops when it is off, so the
same test body works with and without --chaos. A TopologyTracker the test
creates with the default auto_activate=True is picked up automatically and
deactivated afterwards. After the call, chaos.report, chaos.baseline_report
and chaos.contract_report hold the results for teardown code.
Async tests
Coroutine tests run by pytest-asyncio or anyio work. Those plugins run the coroutine in an event-loop context created before the session exists, so the plugin wraps the coroutine function and binds the session inside the task. A tracker created inside the coroutine is still picked up.
Caveats
baseline=Truecalls the test function twice with the same fixture instances. Fixtures that are consumed or mutated by the test should be function-scoped and idempotent, or skip the baseline.- Contracts see only instrumented calls. Work that bypasses the wrappers is invisible to retry, traversal and call-count contracts.
- Dry runs still execute target calls and can spend money; see the safety guide.
Contracts
Contracts are plain Python and also work outside pytest:
from agentic_chaos import chaos_session
from agentic_chaos.contracts import MaxRetries, NoUnhandledError, assert_contracts
error = None
with chaos_session(["rate_limit_storm"]) as session:
try:
my_agent("What's the weather?")
except Exception as exc:
error = exc
report = session.to_report("weather", error=error)
assert_contracts(report, [NoUnhandledError(), MaxRetries(3)]) # raises ContractViolationError
check_contracts(report, contracts, baseline=None) returns a ContractReport
(passed, failures, summary(), as_json(), raise_for_failures()).
report and baseline may be a ChaosReport, its JSON dict, or a path to a
saved report.
Every result has a status:
passedfailedinconclusive— the report cannot show what the contract needs: no baseline, no injected failure, a truncated call log, a run that never ended, or a report written before 0.6 that has nocalls/unhandled_errorfield. An explicit"unhandled_error": nullis evidence that nothing escaped; a missing field is not.
Inconclusive never passes. A contract that cannot be evaluated is not evidence of resilience, so CI fails closed.
| Contract | Spec | Passes when |
|---|---|---|
CompletesWithin(timeout_s) |
completes_within:30 |
report end_time - start_time ≤ timeout_s. Inconclusive without end_time. |
NoUnhandledError() |
no_unhandled_error |
no exception escaped the code under test (unhandled_error is empty). |
MaxRetries(n, step=None) |
max_retries:3,step=search |
no named step was retried more than n times in a row. |
RecoveredAfterFailure(require_step_success=False) |
recovered_after_failure:require_step_success=true |
at least one errored fault event, no escaped exception, and no breaker abort. With require_step_success, every step hit by an injected failure must also succeed later (a fallback alone does not count). Inconclusive when nothing was injected. |
MaxCostImpact(factor, metric="cost_usd", baseline_value=None) |
max_cost_impact:2,metric=calls |
chaos value ≤ factor × baseline. cost_usd uses explicitly recorded cost; calls counts instrumented calls (including dropped ones). Inconclusive without a baseline or when neither run recorded anything. |
NoRetryStorm(max_retries_per_window, window_seconds=60) |
no_retry_storm:5,window_seconds=10 |
at most N retries, across all steps, within any sliding window. Catches a fault on one agent that sets off retries in its callers while each step stays under its own MaxRetries. |
FullTopologyTraversal(expected_nodes=None, ignore=()) |
full_topology_traversal:expected_nodes=Planner\|Reviewer |
every node visited in the baseline (or listed in expected_nodes) was visited under chaos. |
Spec strings are name or name:arg,..., where each arg is positional or
key=value; list values separate items with |. agentic-chaos contracts
list prints them all.
Definitions
- Call log. Each instrumented operation (
chaos_call,chaos_acall, one record per stream) appends a record toChaosReport.calls:sequence,kind(callorstream),step_id,step_name,started_at,duration_s,status(okorerror),error_type, andfault_type/event_idwhen a fault event hit it. The log keeps 10,000 records per session and counts the rest incalls_dropped; retry and storm contracts are inconclusive once anything is dropped. - Retry. A call to a step (same
kindandstep_name, orstep_idif there is no name) whose previous call to that step raised. A success ends the step's streak. Calls with neither name are never treated as retries of each other, andMaxRetriesreports how many such unnamed calls there were. Calls are ordered by when they started, so concurrent calls to the same step can look like retries of one another; give fan-out branches distinct step names. - Visited node. An endpoint of a recorded topology edge, or the
step_nameof an instrumented call. Registering a node alone is not a visit, so a pre-registered reviewer that never runs is reported missing. - Unhandled error. The exception that escaped the code under test, as
recorded by
session.to_report(..., error=exc), the pytest plugin, or the CLI when the script crashes. It includesfault_typeandevent_idwhen it is a fault exception.
CLI
# Gate a script run on contracts (exit 1 unless all pass).
agentic-chaos chaos run app.py --inject rate_limit_storm \
--contract no_unhandled_error --contract max_retries:3 \
--save chaos.json --save-contracts contracts.json
# Record a baseline (dry run), then compare a chaos run against it later.
agentic-chaos chaos run app.py --inject rate_limit_storm --dry-run --save baseline.json
agentic-chaos contracts check chaos.json --baseline baseline.json \
--contract max_cost_impact:2,metric=calls --contract full_topology_traversal
agentic-chaos contracts list
--contract, --baseline and --save-contracts work on chaos run and
agent run. Exit codes: a script's own failing sys.exit() code first, then
1 for a breaker abort, then 1 for a contract that did not pass. Invalid specs
and unreadable baselines fail before the script runs.
Reports and the ecosystem
The new native report fields are additive and default to empty, so older reports still load (contracts that need them are inconclusive on such reports):
| Field | Meaning |
|---|---|
calls |
the call log above |
calls_dropped |
records not kept once the log was full |
unhandled_error |
{type, message, fault_type?, event_id?} or null |
- AgenticLens loads these reports as
WorkflowJSON unchanged; the extra fields are ignored andchaos_eventsare analysed as before. - Control Tower native artifact readers summarize them as before.
- MCP
chaos.export_aios_runand this package'sto_aios_run()carrycalls: {recorded, dropped}andunhandled_errorin the existingio.deepagentlabs.agentic-chaosextension. The per-call records stay in the native report. No core AIOS object changes, and exports still validate against the v0.4-draft run schema. - Contract results are this package's own verdict JSON
(
ContractReport.as_json()), not AIOS evidence. Scoring and release-gate computation remain Agentic Evals' responsibility. A contract is a check over one chaos run, not a release gate.
These checks run in this repository's sibling-integration CI job. No sibling
package needs to change at runtime. Sidecar's optional agentic-chaos extra
was capped below 0.6 and is widened in Sidecar 0.8.1.
Example
examples/chaos_contracts_demo.py: the Python API offline. A resilient agent passes, a fragile one fails, and a skipped reviewer is caught.examples/pytest_chaos/: a pytest suite to run with and without--chaos, including a strict xfail in which a contract catches a fallback that hides an unrecovered step.examples/github-actions-chaos.yml: a CI workflow that runs chaos tests, gates a CLI run and uploads the reports.
