agentverify 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. agentverify-0.3.0/PKG-INFO +760 -0
  2. agentverify-0.3.0/README.md +712 -0
  3. agentverify-0.3.0/agentverify/__init__.py +89 -0
  4. agentverify-0.3.0/agentverify/_step_builder.py +73 -0
  5. agentverify-0.3.0/agentverify/assertions.py +806 -0
  6. {agentverify-0.2.0 → agentverify-0.3.0}/agentverify/cassette/adapters/anthropic.py +63 -0
  7. {agentverify-0.2.0 → agentverify-0.3.0}/agentverify/cassette/adapters/gemini.py +1 -1
  8. agentverify-0.3.0/agentverify/cassette/adapters/openai.py +387 -0
  9. agentverify-0.3.0/agentverify/cassette/recorder.py +550 -0
  10. {agentverify-0.2.0 → agentverify-0.3.0}/agentverify/errors.py +125 -1
  11. agentverify-0.3.0/agentverify/frameworks/langchain.py +69 -0
  12. agentverify-0.3.0/agentverify/frameworks/langgraph.py +113 -0
  13. agentverify-0.3.0/agentverify/frameworks/openai_agents.py +171 -0
  14. agentverify-0.3.0/agentverify/frameworks/strands.py +152 -0
  15. agentverify-0.3.0/agentverify/matchers.py +89 -0
  16. agentverify-0.3.0/agentverify/mocking.py +224 -0
  17. agentverify-0.3.0/agentverify/models.py +302 -0
  18. agentverify-0.3.0/agentverify/probe.py +132 -0
  19. agentverify-0.3.0/agentverify.egg-info/PKG-INFO +760 -0
  20. {agentverify-0.2.0 → agentverify-0.3.0}/agentverify.egg-info/SOURCES.txt +7 -1
  21. {agentverify-0.2.0 → agentverify-0.3.0}/agentverify.egg-info/requires.txt +2 -2
  22. {agentverify-0.2.0 → agentverify-0.3.0}/pyproject.toml +4 -4
  23. agentverify-0.3.0/tests/test_assertions.py +1271 -0
  24. {agentverify-0.2.0 → agentverify-0.3.0}/tests/test_errors.py +68 -0
  25. {agentverify-0.2.0 → agentverify-0.3.0}/tests/test_init.py +8 -0
  26. agentverify-0.3.0/tests/test_matchers.py +91 -0
  27. agentverify-0.3.0/tests/test_mocking.py +247 -0
  28. agentverify-0.3.0/tests/test_models.py +268 -0
  29. agentverify-0.3.0/tests/test_probe.py +175 -0
  30. {agentverify-0.2.0 → agentverify-0.3.0}/tests/test_properties.py +67 -0
  31. {agentverify-0.2.0 → agentverify-0.3.0}/tests/test_recorder.py +382 -0
  32. agentverify-0.3.0/tests/test_step_builder.py +84 -0
  33. agentverify-0.2.0/PKG-INFO +0 -681
  34. agentverify-0.2.0/README.md +0 -633
  35. agentverify-0.2.0/agentverify/__init__.py +0 -54
  36. agentverify-0.2.0/agentverify/assertions.py +0 -324
  37. agentverify-0.2.0/agentverify/cassette/adapters/openai.py +0 -291
  38. agentverify-0.2.0/agentverify/cassette/recorder.py +0 -314
  39. agentverify-0.2.0/agentverify/frameworks/langchain.py +0 -92
  40. agentverify-0.2.0/agentverify/frameworks/langgraph.py +0 -100
  41. agentverify-0.2.0/agentverify/frameworks/openai_agents.py +0 -113
  42. agentverify-0.2.0/agentverify/frameworks/strands.py +0 -93
  43. agentverify-0.2.0/agentverify/matchers.py +0 -48
  44. agentverify-0.2.0/agentverify/models.py +0 -109
  45. agentverify-0.2.0/agentverify.egg-info/PKG-INFO +0 -681
  46. agentverify-0.2.0/tests/test_assertions.py +0 -356
  47. agentverify-0.2.0/tests/test_matchers.py +0 -44
  48. agentverify-0.2.0/tests/test_models.py +0 -136
  49. {agentverify-0.2.0 → agentverify-0.3.0}/LICENSE +0 -0
  50. {agentverify-0.2.0 → agentverify-0.3.0}/agentverify/cassette/__init__.py +0 -0
  51. {agentverify-0.2.0 → agentverify-0.3.0}/agentverify/cassette/adapters/__init__.py +0 -0
  52. {agentverify-0.2.0 → agentverify-0.3.0}/agentverify/cassette/adapters/base.py +0 -0
  53. {agentverify-0.2.0 → agentverify-0.3.0}/agentverify/cassette/adapters/bedrock.py +0 -0
  54. {agentverify-0.2.0 → agentverify-0.3.0}/agentverify/cassette/adapters/litellm.py +0 -0
  55. {agentverify-0.2.0 → agentverify-0.3.0}/agentverify/cassette/io.py +0 -0
  56. {agentverify-0.2.0 → agentverify-0.3.0}/agentverify/cassette/sanitize.py +0 -0
  57. {agentverify-0.2.0 → agentverify-0.3.0}/agentverify/fixtures.py +0 -0
  58. {agentverify-0.2.0 → agentverify-0.3.0}/agentverify/frameworks/__init__.py +0 -0
  59. {agentverify-0.2.0 → agentverify-0.3.0}/agentverify/markers.py +0 -0
  60. {agentverify-0.2.0 → agentverify-0.3.0}/agentverify/plugin.py +0 -0
  61. {agentverify-0.2.0 → agentverify-0.3.0}/agentverify/py.typed +0 -0
  62. {agentverify-0.2.0 → agentverify-0.3.0}/agentverify.egg-info/dependency_links.txt +0 -0
  63. {agentverify-0.2.0 → agentverify-0.3.0}/agentverify.egg-info/entry_points.txt +0 -0
  64. {agentverify-0.2.0 → agentverify-0.3.0}/agentverify.egg-info/top_level.txt +0 -0
  65. {agentverify-0.2.0 → agentverify-0.3.0}/setup.cfg +0 -0
  66. {agentverify-0.2.0 → agentverify-0.3.0}/tests/test_base_adapter.py +0 -0
  67. {agentverify-0.2.0 → agentverify-0.3.0}/tests/test_cassette_io.py +0 -0
  68. {agentverify-0.2.0 → agentverify-0.3.0}/tests/test_fixtures.py +0 -0
  69. {agentverify-0.2.0 → agentverify-0.3.0}/tests/test_markers.py +0 -0
  70. {agentverify-0.2.0 → agentverify-0.3.0}/tests/test_plugin.py +0 -0
  71. {agentverify-0.2.0 → agentverify-0.3.0}/tests/test_sanitize.py +0 -0
@@ -0,0 +1,760 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentverify
3
+ Version: 0.3.0
4
+ Summary: Deterministic testing for AI agents. Assert agent actions, not vibes.
5
+ Author: simukappu
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/simukappu/agentverify
8
+ Project-URL: Repository, https://github.com/simukappu/agentverify
9
+ Project-URL: Issues, https://github.com/simukappu/agentverify/issues
10
+ Project-URL: Changelog, https://github.com/simukappu/agentverify/blob/main/CHANGELOG.md
11
+ Keywords: pytest,testing,ai,agent,llm,tool-calls,cassette,deterministic,ci
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Framework :: Pytest
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Software Development :: Testing
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: PyYAML
27
+ Requires-Dist: pytest>=7
28
+ Provides-Extra: openai
29
+ Requires-Dist: openai; extra == "openai"
30
+ Provides-Extra: anthropic
31
+ Requires-Dist: anthropic; extra == "anthropic"
32
+ Provides-Extra: gemini
33
+ Requires-Dist: google-genai; extra == "gemini"
34
+ Provides-Extra: bedrock
35
+ Requires-Dist: boto3; extra == "bedrock"
36
+ Provides-Extra: litellm
37
+ Requires-Dist: litellm; extra == "litellm"
38
+ Provides-Extra: all
39
+ Requires-Dist: openai; extra == "all"
40
+ Requires-Dist: anthropic; extra == "all"
41
+ Requires-Dist: google-genai; extra == "all"
42
+ Requires-Dist: boto3; extra == "all"
43
+ Requires-Dist: litellm; extra == "all"
44
+ Provides-Extra: dev
45
+ Requires-Dist: hypothesis; extra == "dev"
46
+ Requires-Dist: pytest-cov; extra == "dev"
47
+ Dynamic: license-file
48
+
49
+ # agentverify
50
+
51
+ [![PyPI](https://img.shields.io/pypi/v/agentverify)](https://pypi.org/project/agentverify/)
52
+ [![Downloads](https://img.shields.io/pepy/dt/agentverify)](https://pepy.tech/projects/agentverify)
53
+ [![CI](https://github.com/simukappu/agentverify/actions/workflows/ci.yml/badge.svg)](https://github.com/simukappu/agentverify/actions/workflows/ci.yml)
54
+ [![Coverage](https://coveralls.io/repos/github/simukappu/agentverify/badge.svg?branch=main)](https://coveralls.io/github/simukappu/agentverify?branch=main)
55
+ [![License](http://img.shields.io/badge/license-MIT-blue.svg?style=flat)](LICENSE)
56
+
57
+ **pytest for AI agents.** Assert agent actions, not vibes.
58
+
59
+ agentverify is a pytest plugin for deterministic testing of AI agent actions. Record real LLM calls once and replay them in CI with zero cost, or run against a live LLM with tolerance-aware assertions. Either way, assert exactly what your agent did: which tools it called, what data flowed between steps, how much it cost, and whether it stayed within your safety rules.
60
+
61
+ Specifically:
62
+
63
+ - **Tool call sequences** — exact order, subsequence, or set membership, with regex / wildcard argument matching
64
+ - **Step-level execution** — each LLM call is a step; assert what tool was called at step N, what the step's output was, and that step N's input references data produced by step M
65
+ - **Budgets** — token counts, cost in USD, end-to-end latency
66
+ - **Safety** — a list of tools that must never be called, no matter what the LLM decides
67
+
68
+ Built-in adapters cover LangChain, LangGraph, Strands Agents, and OpenAI Agents SDK, and a small custom converter plugs in anything else — pure-Python ReAct loops, the Anthropic / OpenAI SDK directly, or frameworks not listed above. LLM providers supported: OpenAI, Amazon Bedrock, Google Gemini, Anthropic, and LiteLLM. Cassettes are human-readable YAML — commit them to git, review in PRs, run in CI without an API key.
69
+
70
+ ## Who is this for?
71
+
72
+ - You've shipped an agent that calls tools in a loop and need CI-safe regression tests for it
73
+ - Your agent calls multiple tools and you've hit bugs like "it ignored the tool result" or "it called the wrong tool"
74
+ - You want prompt and tool changes reviewed in PRs via a concrete diff, not an informal "looks fine locally" signoff
75
+ - You're building on MCP servers and need to pin down which tools get called with which arguments
76
+ - You want token, cost, and latency budgets enforced in CI, not caught only on the billing dashboard
77
+
78
+ ## Install
79
+
80
+ ```bash
81
+ pip install agentverify
82
+ ```
83
+
84
+ ## Quick Start
85
+
86
+ Copy this into `test_agent.py` and run `pytest`. No LLM calls, no API keys, no cassettes needed — just pure assertions on an `ExecutionResult`.
87
+
88
+ ```python
89
+ from agentverify import (
90
+ ExecutionResult, ToolCall, ANY,
91
+ assert_tool_calls, assert_cost, assert_no_tool_call, assert_final_output,
92
+ )
93
+
94
+ # Build an ExecutionResult from your agent's output (or a dict)
95
+ result = ExecutionResult.from_dict({
96
+ "tool_calls": [
97
+ {"name": "get_location", "arguments": {"city": "Tokyo"}},
98
+ {"name": "get_weather", "arguments": {"lat": 35.6, "lon": 139.7}},
99
+ ],
100
+ "token_usage": {"input_tokens": 50, "output_tokens": 30},
101
+ "total_cost_usd": 0.002,
102
+ "final_output": "The weather in Tokyo is sunny, 22°C.",
103
+ })
104
+
105
+ def test_tool_sequence():
106
+ assert_tool_calls(result, expected=[
107
+ ToolCall("get_location", {"city": "Tokyo"}),
108
+ ToolCall("get_weather", {"lat": ANY, "lon": ANY}),
109
+ ])
110
+
111
+ def test_budget():
112
+ assert_cost(result, max_tokens=500, max_cost_usd=0.01)
113
+
114
+ def test_safety():
115
+ assert_no_tool_call(result, forbidden_tools=["delete_user", "drop_table"])
116
+
117
+ def test_output():
118
+ assert_final_output(result, contains="Tokyo")
119
+ ```
120
+
121
+ > **Using a real agent framework?** Walk through the [Real-World Examples](#real-world-examples) below for end-to-end tests with cassettes, or jump straight to the [Examples](#examples) table for the full list of runnable samples.
122
+
123
+ ```
124
+ $ pytest test_agent.py -v
125
+ test_agent.py::test_tool_sequence PASSED
126
+ test_agent.py::test_budget PASSED
127
+ test_agent.py::test_safety PASSED
128
+ test_agent.py::test_output PASSED
129
+ ```
130
+
131
+ ## Real-World Examples
132
+
133
+ End-to-end examples that showcase different agent patterns and testing angles. All run with pre-recorded cassettes — no API keys needed.
134
+
135
+ ### Strands Weather Forecaster — Two-Step ReAct
136
+
137
+ Test the [Strands Weather Forecaster](https://strandsagents.com/docs/examples/python/weather_forecaster/) — an official Strands Agents sample. The agent uses the `http_request` tool to call the National Weather Service API, which returns the forecast URL for the requested location; the agent then calls that URL to get the actual forecast. Two steps, one piece of data flowing between them — the simplest shape of a ReAct loop.
138
+
139
+ ```python
140
+ import pytest
141
+ from agentverify import (
142
+ assert_tool_calls, assert_cost, assert_latency,
143
+ assert_no_tool_call, assert_final_output, assert_all,
144
+ ToolCall, MATCHES,
145
+ )
146
+
147
+ # Import the weather agent from the Strands sample
148
+ # (see https://strandsagents.com/docs/examples/python/weather_forecaster/)
149
+ from weather_agent import weather_agent
150
+
151
+
152
+ @pytest.mark.agentverify
153
+ def test_weather_agent(cassette):
154
+ """Verify tool sequence, budget, latency, safety, and final output in one go."""
155
+ with cassette("weather_seattle.yaml", provider="bedrock") as rec:
156
+ weather_agent("What's the weather in Seattle?")
157
+
158
+ result = rec.to_execution_result()
159
+ assert_all(
160
+ result,
161
+ # Two http_request calls with distinct URL shapes — location lookup then forecast
162
+ lambda r: assert_tool_calls(r, expected=[
163
+ ToolCall("http_request", {"method": "GET", "url": MATCHES(r"/points/")}),
164
+ ToolCall("http_request", {"method": "GET", "url": MATCHES(r"/forecast")}),
165
+ ]),
166
+ lambda r: assert_cost(r, max_tokens=15000),
167
+ lambda r: assert_latency(r, max_ms=10_000),
168
+ lambda r: assert_no_tool_call(r, forbidden_tools=["write_file", "execute_command"]),
169
+ lambda r: assert_final_output(r, contains="Seattle"),
170
+ )
171
+ ```
172
+
173
+ Want to go deeper? Step-level assertions verify the ReAct structure and the data flow between the two steps:
174
+
175
+ ```python
176
+ from agentverify import assert_step, assert_step_uses_result_from
177
+
178
+ @pytest.mark.agentverify
179
+ def test_weather_agent_steps(cassette):
180
+ with cassette("weather_seattle.yaml", provider="bedrock") as rec:
181
+ weather_agent("What's the weather in Seattle?")
182
+ result = rec.to_execution_result()
183
+
184
+ # First step must call /points/ to discover the forecast office
185
+ assert_step(result, step=0, expected_tool=ToolCall(
186
+ "http_request", {"method": "GET", "url": MATCHES(r"/points/")},
187
+ ), partial_args=True)
188
+
189
+ # Second step must call /forecast/ AND use data from the first step's result
190
+ assert_step(result, step=1, expected_tool=ToolCall(
191
+ "http_request", {"method": "GET", "url": MATCHES(r"/forecast")},
192
+ ), partial_args=True)
193
+ assert_step_uses_result_from(result, step=1, depends_on=0)
194
+ ```
195
+
196
+ `MATCHES(pattern)` is a regex matcher — see [Assertion Modes](#assertion-modes) for details on `ANY`, `MATCHES`, `OrderMode`, and `partial_args`. Step assertions are covered in [Step-Level Assertions](#step-level-assertions).
197
+
198
+ ### OpenAI Agents SDK LLM as a Judge — Freezing a Probabilistic Refinement Loop
199
+
200
+ Test the [OpenAI Agents SDK `agent_patterns/llm_as_a_judge.py`](https://github.com/openai/openai-agents-python/blob/main/examples/agent_patterns/llm_as_a_judge.py) sample: a generator writes a story outline, an evaluator grades it with a structured `{feedback, score}` verdict, and the loop re-runs the generator with that feedback until the evaluator accepts the draft. Everything about this agent is probabilistic — how many rounds it takes, what the feedback says, whether the rewrite incorporates it. Recording one run freezes all of it into a deterministic test.
201
+
202
+ The headline assertion is the feedback chain: round 2's generator must actually consume round 1's evaluator feedback, not silently regenerate the same outline.
203
+
204
+ ```python
205
+ import pytest
206
+ from agentverify import assert_step_uses_result_from, assert_step_output
207
+
208
+ # Import the judge loop from the example
209
+ # (see examples/openai-agents-llm-as-a-judge/)
210
+ from agent import run_judge_loop_sync
211
+
212
+
213
+ @pytest.mark.agentverify
214
+ def test_generator_consumes_evaluator_feedback(cassette):
215
+ """Round 2's regeneration must reference the evaluator's round 1 feedback."""
216
+ with cassette("detective_in_space.yaml", provider="openai") as rec:
217
+ run_judge_loop_sync("A detective story in space.", max_rounds=3)
218
+ result = rec.to_execution_result()
219
+
220
+ # Step 1 is the evaluator's first verdict; it must reject on the first try.
221
+ assert_step_output(
222
+ result, step=1, matches=r'"score"\s*:\s*"(needs_improvement|fail)"'
223
+ )
224
+ # Step 2 (generator, round 2) must actually consume step 1's feedback —
225
+ # catches "agent regenerated but ignored the judge" bugs.
226
+ assert_step_uses_result_from(result, step=2, depends_on=1)
227
+ ```
228
+
229
+ `assert_step_uses_result_from` finds the multi-line evaluator feedback inside the next step's user message even when JSON serialization would escape the newlines; the match succeeds across that boundary. Because each `Runner.run` call is recorded individually, the example builds the `ExecutionResult` straight off the cassette recorder — `from_openai_agents` is the right adapter for single-run agents, but the cassette layer is the natural single source of truth when the workflow spans several runs.
230
+
231
+ See [`examples/openai-agents-llm-as-a-judge/`](examples/openai-agents-llm-as-a-judge/) for the full loop, the cassette, and the remaining flat + step-level assertions (budget, safety, pass-verdict reached).
232
+
233
+ ### LangGraph + LangChain — Multi-Agent Handoff and MCP Tool Loops
234
+
235
+ Full-depth examples for the LangChain ecosystem live under [`examples/`](examples/):
236
+
237
+ - **[LangGraph multi-agent supervisor](examples/langgraph-multi-agent-supervisor/)** — a supervisor routes to a research expert and a math expert; each `add` in the math chain must consume the previous `add`'s result. `assert_step_uses_result_from` matches the numeric running total (`"231317.0"` produced as a string, consumed as `231317` int/float) across type boundaries, with digit-boundary checks that keep `"231"` from falsely appearing inside `1231`.
238
+ - **[LangChain GitHub issue triage](examples/langchain-issue-triage/)** — a `create_react_agent` drives the GitHub MCP server to list issues, read one, propose labels. The step-level tests verify that the `issue_number` passed to `get_issue` actually came from the preceding `list_issues` response, and that destructive tools like `close_issue` are never called.
239
+
240
+ Each example ships with its own pre-recorded cassette, a detailed `README.md`, and a full test file — pick whichever framework fits your stack.
241
+
242
+ ## Build an ExecutionResult
243
+
244
+ Every agentverify assertion takes an `ExecutionResult`. You can build one three ways:
245
+
246
+ 1. **From a dict** — convenient for quick tests and fixtures, as in the [Quick Start](#quick-start).
247
+ 2. **From a built-in adapter** — one-liner for LangChain, LangGraph, Strands Agents, OpenAI Agents SDK. See [Framework Integration](#framework-integration).
248
+ 3. **From a custom converter** — for frameworks or pure-Python agents not covered by a built-in adapter, map your output to the schema below. See [`examples/custom-converter-python-agent/`](examples/custom-converter-python-agent/) for a complete walkthrough with an Anthropic SDK agent, including a pre-recorded cassette and step-level tests.
249
+
250
+ `ExecutionResult.from_dict()` accepts these keys:
251
+
252
+ | Key | Type | Description |
253
+ |---|---|---|
254
+ | `steps` | `list[dict]` | Each step dict has `index` (int), `source` (`"llm"` / `"probe"` / `"tool"`), `tool_calls` (list), `tool_results` (list), `output` (str or None), `input_context` (dict or None), `name` (str or None). See [Step-Level Assertions](#step-level-assertions) |
255
+ | `tool_calls` | `list[dict]` | **Shortcut for single-step cases** — when `steps` is absent, a flat `tool_calls` list is wrapped into a single synthetic step. Used throughout the [Quick Start](#quick-start). Each dict has `name` (str, required), `arguments` (dict, optional), `result` (any, optional) |
256
+ | `token_usage` | `dict` or `None` | `{"input_tokens": int, "output_tokens": int}` |
257
+ | `total_cost_usd` | `float` or `None` | Total cost in USD (must be set manually — not auto-calculated from tokens or populated from cassettes) |
258
+ | `duration_ms` | `float` or `None` | Wall-clock duration in milliseconds (auto-populated by the `cassette` fixture and `MockLLM`) |
259
+ | `final_output` | `str` or `None` | The agent's final text response |
260
+
261
+ `ExecutionResult.from_json(json_string)` and `to_dict()` / `to_json()` are also available for serialization.
262
+
263
+ ## Record & Replay with Cassettes
264
+
265
+ Record real LLM API calls once. Replay them in CI forever — zero cost, deterministic.
266
+
267
+ ```python
268
+ import pytest
269
+ from agentverify import assert_tool_calls, ToolCall, ANY
270
+
271
+ @pytest.mark.agentverify
272
+ def test_weather_agent(cassette):
273
+ with cassette("weather_agent.yaml", provider="openai") as rec:
274
+ # Replace this with your actual agent invocation, e.g.:
275
+ # agent.run("What's the weather in Tokyo?")
276
+ run_my_agent("What's the weather in Tokyo?")
277
+
278
+ # rec.to_execution_result() is called AFTER the with block exits
279
+ result = rec.to_execution_result()
280
+ assert_tool_calls(result, expected=[
281
+ ToolCall("get_location", {"city": "Tokyo"}),
282
+ ToolCall("get_weather", {"lat": ANY, "lon": ANY}),
283
+ ])
284
+ ```
285
+
286
+ The `cassette` fixture is provided by the agentverify pytest plugin. It creates an `LLMCassetteRecorder` that intercepts LLM SDK calls (not HTTP — it patches the SDK's chat completion method directly). Use `@pytest.mark.agentverify` to mark your test, and call your agent code inside the `with cassette(...)` block. After the block exits, call `rec.to_execution_result()` to build the result for assertions.
287
+
288
+ Record the cassette once, then replay it forever:
289
+
290
+ ```bash
291
+ # First run: record real LLM calls to cassette file
292
+ pytest --cassette-mode=record
293
+
294
+ # All subsequent runs: replay from cassette (zero cost, deterministic)
295
+ pytest
296
+ ```
297
+
298
+ Cassettes are human-readable YAML (or JSON). Commit them to git, review in PRs.
299
+
300
+ **Cassette modes:**
301
+
302
+ | Mode | Behavior |
303
+ |---|---|
304
+ | `AUTO` (default) | If cassette file exists → REPLAY. Otherwise → call real LLM API but don't save (no cassette file is created). |
305
+ | `RECORD` | Always call real LLM API and save to cassette file. |
306
+ | `REPLAY` | Always replay from cassette file. Raises error if file is missing. |
307
+
308
+ ### Stale Cassette Detection
309
+
310
+ Cassette replay verifies request content by default so a stale cassette fails loudly instead of returning the old response. Each replay request is compared against the recorded request:
311
+ - **Model name**: must match exactly (empty model in either side skips the check)
312
+ - **Tool names**: the sorted list of tool names must match (empty tools in either side skips the check)
313
+
314
+ A `CassetteRequestMismatchError` is raised on mismatch with a clear message indicating which field differs and suggesting re-recording.
315
+
316
+ To disable matching (e.g., during migration):
317
+
318
+ ```bash
319
+ # CLI: disable for all tests
320
+ pytest --no-cassette-match-requests
321
+ ```
322
+
323
+ ```python
324
+ # Per-test: disable in the cassette factory call
325
+ with cassette("my_test.yaml", provider="openai", match_requests=False) as rec:
326
+ run_my_agent("What's the weather?")
327
+ ```
328
+
329
+ ### Keeping Cassettes Current
330
+
331
+ Cassettes drift when your prompts, tool schemas, or model versions change. agentverify's stale detection catches model-name and tool-name mismatches automatically (see [Stale Cassette Detection](#stale-cassette-detection)); a prompt-only change won't invalidate the cassette unless the model's tool-call output differs. In practice:
332
+
333
+ - Re-record on major prompt rewrites or tool schema changes
334
+ - Pin model versions (`gpt-4o-2024-08-06`, not `gpt-4o`) in your agent code so cassettes don't silently shift underneath you
335
+ - Treat cassette diffs as part of the PR review — a YAML diff next to the prompt diff is a feature, not noise
336
+ - Use `MATCHES` and `partial_args=True` to assert on the stable parts of tool arguments, so small wording changes in LLM outputs don't cascade into test failures
337
+
338
+ ### Cassette Sanitization
339
+
340
+ Cassette files are sanitized by default when recording. API keys, tokens, and other sensitive data are automatically redacted before saving to disk.
341
+
342
+ Built-in patterns cover:
343
+ - OpenAI API keys (`sk-...`)
344
+ - Anthropic API keys (`sk-ant-...`)
345
+ - AWS access keys (`AKIA...`)
346
+ - Bearer tokens (`Bearer ...`)
347
+
348
+ Add custom patterns:
349
+
350
+ ```python
351
+ from agentverify import SanitizePattern
352
+
353
+ custom_patterns = [
354
+ SanitizePattern(name="internal_token", pattern=r"tok-[a-f0-9]{32}", replacement="tok-***REDACTED***"),
355
+ ]
356
+
357
+ with cassette("test.yaml", provider="openai", sanitize=custom_patterns) as rec:
358
+ run_my_agent("Do something")
359
+ ```
360
+
361
+ Pass `sanitize=False` to disable sanitization entirely (not recommended).
362
+
363
+ ## Assertion Modes
364
+
365
+ ```python
366
+ from agentverify import assert_tool_calls, OrderMode, ToolCall, ANY, MATCHES
367
+
368
+ # Exact match — same tools, same order, same count (default)
369
+ assert_tool_calls(result, expected=[...])
370
+
371
+ # Subsequence — these tools appeared in this order (other calls in between are OK)
372
+ assert_tool_calls(result, expected=[...], order=OrderMode.IN_ORDER)
373
+
374
+ # Set membership — these tools were called (order doesn't matter)
375
+ assert_tool_calls(result, expected=[...], order=OrderMode.ANY_ORDER)
376
+
377
+ # Partial args — only check the keys you care about
378
+ assert_tool_calls(result, expected=[
379
+ ToolCall("search", {"query": "Tokyo"}),
380
+ ], partial_args=True)
381
+
382
+ # ANY wildcard — ignore a specific argument value
383
+ assert_tool_calls(result, expected=[
384
+ ToolCall("get_weather", {"lat": ANY, "lon": ANY}),
385
+ ])
386
+
387
+ # MATCHES regex — verify a string argument follows a pattern
388
+ # (re.search semantics — use ^/$ anchors for full match)
389
+ assert_tool_calls(result, expected=[
390
+ ToolCall("send_email", {"to": MATCHES(r"^[\w.+-]+@example\.com$")}),
391
+ ToolCall("log_event", {"event_id": MATCHES(r"^evt_[a-f0-9]{16}$")}),
392
+ ])
393
+ ```
394
+
395
+ Collect all failures at once instead of stopping at the first:
396
+
397
+ ```python
398
+ from agentverify import assert_all
399
+ assert_all(
400
+ result,
401
+ lambda r: assert_tool_calls(r, expected=[...]),
402
+ lambda r: assert_cost(r, max_tokens=1000),
403
+ lambda r: assert_latency(r, max_ms=3000),
404
+ lambda r: assert_no_tool_call(r, forbidden_tools=["delete_user"]),
405
+ lambda r: assert_final_output(r, contains="Tokyo"),
406
+ )
407
+ ```
408
+
409
+ ### Cost Assertions
410
+
411
+ `assert_cost()` enforces token and dollar budgets on an agent run. Either bound is optional — pass `max_tokens` only, `max_cost_usd` only, or both together:
412
+
413
+ ```python
414
+ from agentverify import assert_cost
415
+
416
+ # Token budget only
417
+ assert_cost(result, max_tokens=500)
418
+
419
+ # Dollar budget only
420
+ assert_cost(result, max_cost_usd=0.01)
421
+
422
+ # Both — fail if either is exceeded
423
+ assert_cost(result, max_tokens=500, max_cost_usd=0.01)
424
+ ```
425
+
426
+ `result.token_usage` is populated automatically by the `cassette` fixture, `MockLLM`, and every built-in framework adapter. `result.total_cost_usd` must be set manually (see [Build an ExecutionResult](#build-an-executionresult)) — agentverify does not compute dollar costs from tokens, since the pricing table would need per-model maintenance and get stale quickly.
427
+
428
+ ### Latency Assertions
429
+
430
+ `assert_latency()` enforces a response time SLA on your agent. When you use the `cassette` fixture, wall-clock duration is captured automatically on context exit and exposed as `ExecutionResult.duration_ms`:
431
+
432
+ ```python
433
+ from agentverify import assert_latency
434
+
435
+ @pytest.mark.agentverify
436
+ def test_weather_agent_latency(cassette):
437
+ with cassette("weather_seattle.yaml", provider="bedrock") as rec:
438
+ weather_agent("What's the weather in Seattle?")
439
+
440
+ result = rec.to_execution_result()
441
+ # Fail if the whole agent run took more than 3 seconds
442
+ assert_latency(result, max_ms=3000)
443
+ ```
444
+
445
+ During cassette **replay**, the measured duration reflects replay time (typically milliseconds), not the original call time. Capture latency data during **record** mode, or set `duration_ms` manually via [`ExecutionResult.from_dict()`](#build-an-executionresult).
446
+
447
+ ### Final Output Assertions
448
+
449
+ `assert_final_output()` verifies the agent's final text response. Use `contains` for substring checks, `equals` for exact match, or `matches` for regex:
450
+
451
+ ```python
452
+ from agentverify import assert_final_output
453
+
454
+ # Substring check
455
+ assert_final_output(result, contains="Tokyo")
456
+
457
+ # Exact match
458
+ assert_final_output(result, equals="The weather in Tokyo is sunny, 22°C.")
459
+
460
+ # Regex match
461
+ assert_final_output(result, matches=r"\d+°C")
462
+ ```
463
+
464
+ ### Strict Mode
465
+
466
+ `assert_cost()` and `assert_latency()` silently pass when the underlying data (`token_usage`, `total_cost_usd`, `duration_ms`) is `None` — useful during cassette replay where some data may be unavailable. Pass `strict=True` to require the data to be present:
467
+
468
+ ```python
469
+ # Fails if token_usage or total_cost_usd is None
470
+ assert_cost(result, max_tokens=500, max_cost_usd=0.01, strict=True)
471
+
472
+ # Fails if duration_ms is None
473
+ assert_latency(result, max_ms=3000, strict=True)
474
+ ```
475
+
476
+ ## Step-Level Assertions
477
+
478
+ For agents that make multiple LLM calls per execution (ReAct, Plan-and-Execute, multi-hop retrieval, workflow-style), agentverify exposes the full step structure — not just a flat list of tool calls.
479
+
480
+ `ExecutionResult.steps` is the single source of truth; `result.tool_calls` remains as a derived flat view for simpler cases.
481
+
482
+ > The [Real-World Examples](#real-world-examples) above show `assert_step` and `assert_step_uses_result_from` in action on Strands and the OpenAI Agents SDK; the LangGraph, LangChain, and pure-Python custom-converter examples linked from there exercise the same API on different agent shapes. This section covers the full API.
483
+
484
+ ```python
485
+ from agentverify import assert_step, assert_step_output, ToolCall, MATCHES
486
+
487
+ @pytest.mark.agentverify
488
+ def test_plan_and_execute(cassette):
489
+ with cassette("trip_planner.yaml", provider="openai") as rec:
490
+ plan_trip("2 nights in Shibuya, Tokyo")
491
+ result = rec.to_execution_result()
492
+
493
+ # Step 0: agent plans what to search for
494
+ assert_step_output(result, step=0, contains="flights")
495
+
496
+ # Step 1: agent calls the flight search tool
497
+ assert_step(result, step=1, expected_tool=ToolCall("search_flights", {"city": "Tokyo"}))
498
+
499
+ # Step 2: agent calls the hotel search with the neighborhood the user asked for
500
+ assert_step(result, step=2, expected_tool=ToolCall("search_hotels", {"area": MATCHES(r"Shibuya")}))
501
+ ```
502
+
503
+ ### Assert Step-to-Step Data Flow
504
+
505
+ `assert_step_uses_result_from()` verifies that one step's input references data produced by another — catches "agent ignored the tool result" bugs.
506
+
507
+ ```python
508
+ from agentverify import assert_step_uses_result_from
509
+
510
+ # Verify step 2 (hotel search) actually uses the result of step 1 (flight search)
511
+ assert_step_uses_result_from(result, step=2, depends_on=1)
512
+
513
+ # Restrict to a specific channel
514
+ assert_step_uses_result_from(result, step=2, depends_on=1, via="tool_result")
515
+ ```
516
+
517
+ The check searches for any produced value (step M's `tool_results`, `tool_calls[*].result`, or `output`) inside step N's `input_context` or `tool_calls[*].arguments`. Strings are substring-matched; primitives (numbers, booleans) are matched structurally inside containers; JSON-encoded tool results are descended automatically.
518
+
519
+ To see this concretely with a different example: if step 0's `list_issues` tool returned `[{"number": 1}, {"number": 2}]` and step 1 called `get_issue(issue_number=2)`, the check finds that the number `2` produced by step 0 shows up in step 1's arguments — data flows correctly. This is exactly the pattern the [LangChain GitHub issue triage example](examples/langchain-issue-triage/) verifies.
520
+
521
+ **Works on cassette replay.** Cassette recorders don't record tool execution results directly, but agentverify backfills them from the next step's `input_context` so you can assert data flow without any special adapter setup.
522
+
523
+ ### Workflow-Style Agents with `step_probe`
524
+
525
+ Many production agents aren't pure ReAct loops — they mix LLM calls with caching, state management, validation, and conditional branches. `step_probe()` lets you mark logical step boundaries in your agent code so tests can assert on those non-LLM steps.
526
+
527
+ ```python
528
+ # agent.py
529
+ from agentverify import step_probe
530
+
531
+ def run_agent(query: str) -> str:
532
+ with step_probe("fetch_cache") as p:
533
+ cached = cache.get(query)
534
+ p.set_tool_result({"hit": cached is not None})
535
+ if cached:
536
+ return cached
537
+ with step_probe("call_llm"):
538
+ response = llm.invoke(query)
539
+ with step_probe("postprocess", output="formatted"):
540
+ return format_output(response)
541
+ ```
542
+
543
+ ```python
544
+ # test_agent.py
545
+ def test_cache_miss_path():
546
+ with MockLLM([mock_response(content="42")], provider="openai") as rec:
547
+ run_agent("what's the answer?")
548
+ result = rec.to_execution_result()
549
+ assert_step(result, name="fetch_cache", expected_tools=[]) # probe with no tool calls
550
+ assert_step_output(result, name="call_llm", contains="42") # the LLM step returned the answer
551
+ assert_step_output(result, name="postprocess", equals="formatted")
552
+ ```
553
+
554
+ **`step_probe` is a zero-cost no-op outside of test recording contexts** — when no `LLMCassetteRecorder` or `MockLLM` is active, it's a pass-through context manager. Safe to leave in production code.
555
+
556
+ ## Framework Integration
557
+
558
+ ### Built-in Adapters
559
+
560
+ agentverify ships with built-in adapters for popular agent frameworks. No converter function needed — just import and call:
561
+
562
+ | Framework | Adapter | Input |
563
+ |---|---|---|
564
+ | [LangChain](https://github.com/langchain-ai/langchain) | `from agentverify.frameworks.langchain import from_langchain` | `AgentExecutor.invoke()` dict |
565
+ | [LangGraph](https://github.com/langchain-ai/langgraph) | `from agentverify.frameworks.langgraph import from_langgraph` | `create_react_agent` result dict |
566
+ | [Strands Agents](https://github.com/strands-agents/sdk-python) | `from agentverify.frameworks.strands import from_strands` | `AgentResult` |
567
+ | [OpenAI Agents SDK](https://github.com/openai/openai-agents-python) | `from agentverify.frameworks.openai_agents import from_openai_agents` | `RunResult` |
568
+
569
+ For LangChain, pass the conversation history `messages` list as a second argument to capture token usage:
570
+
571
+ ```python
572
+ from agentverify.frameworks.langchain import from_langchain
573
+
574
+ execution_result = from_langchain(result, messages=memory.chat_memory.messages)
575
+ ```
576
+
577
+ LangGraph, Strands, and OpenAI Agents adapters take the raw agent output directly:
578
+
579
+ ```python
580
+ from agentverify.frameworks.langgraph import from_langgraph
581
+ from agentverify.frameworks.strands import from_strands
582
+ from agentverify.frameworks.openai_agents import from_openai_agents
583
+
584
+ # LangGraph: create_react_agent / create_supervisor result
585
+ execution_result = from_langgraph(app.invoke({"messages": [...]}))
586
+
587
+ # Strands: agent call result
588
+ execution_result = from_strands(weather_agent("What's the weather in Seattle?"))
589
+
590
+ # OpenAI Agents SDK: Runner.run_sync() / await Runner.run() result
591
+ execution_result = from_openai_agents(Runner.run_sync(agent, "your prompt"))
592
+ ```
593
+
594
+ ### Custom Converters
595
+
596
+ For other frameworks, build an `ExecutionResult` from your agent's output using a small converter function. See [`examples/custom-converter-python-agent/`](examples/custom-converter-python-agent/) for a complete reference — an Anthropic SDK ReAct agent paired with a ~80-line `converter.py`, a recorded cassette, and step-level tests.
597
+
598
+ ## Supported LLM Providers
599
+
600
+ | Provider | Extra |
601
+ |---|---|
602
+ | OpenAI | `pip install agentverify[openai]` |
603
+ | Amazon Bedrock | `pip install agentverify[bedrock]` |
604
+ | Google Gemini | `pip install agentverify[gemini]` |
605
+ | Anthropic | `pip install agentverify[anthropic]` |
606
+ | LiteLLM | `pip install agentverify[litellm]` |
607
+ | All providers | `pip install agentverify[all]` |
608
+
609
+ ## Tool Mocking
610
+
611
+ `MockLLM` replays a list of predefined LLM responses that you define in code. Test your agent's routing logic without a cassette or a real LLM call — useful when you haven't recorded yet, or when you want to drive the agent through hypothetical responses (errors, edge cases) that would be awkward to elicit from a real model.
612
+
613
+ ```python
614
+ import pytest
615
+ from agentverify import (
616
+ MockLLM, mock_response, ToolCall,
617
+ assert_tool_calls, assert_no_tool_call,
618
+ )
619
+
620
+ def test_agent_routes_to_weather_tool():
621
+ with MockLLM([
622
+ mock_response(tool_calls=[("get_weather", {"city": "Tokyo"})]),
623
+ mock_response(content="Tokyo is sunny, 22°C."),
624
+ ], provider="openai") as rec:
625
+ my_agent("What's the weather in Tokyo?")
626
+
627
+ result = rec.to_execution_result()
628
+ assert_tool_calls(result, expected=[ToolCall("get_weather", {"city": "Tokyo"})])
629
+ assert_no_tool_call(result, forbidden_tools=["delete_user"])
630
+ ```
631
+
632
+ `mock_response()` accepts:
633
+
634
+ - `content` — the final text message the LLM should return.
635
+ - `tool_calls` — a list of `(name, arguments)` tuples or `{"name": ..., "arguments": ...}` dicts.
636
+ - `input_tokens` / `output_tokens` — optional token usage to report.
637
+
638
+ Provide one `mock_response(...)` per LLM call your agent is expected to make. If your agent makes more calls than you've queued, `MockLLM` raises `CassetteMissingRequestError` so under-specified tests fail loudly.
639
+
640
+ ### When to Use MockLLM vs Cassettes
641
+
642
+ | Use MockLLM when | Use cassettes when |
643
+ |---|---|
644
+ | You haven't recorded a cassette yet | You want fidelity with real LLM output |
645
+ | You want to test hypothetical scenarios (errors, edge cases) | You want to catch prompt-regression bugs |
646
+ | You're doing behavior-driven tests of routing | You're regression-testing full agent runs |
647
+ | You want zero dependency on any API key | You've already got recordings to replay |
648
+
649
+ ## CI Integration
650
+
651
+ agentverify is designed for CI pipelines. Commit your cassette files to git and replay them in CI with zero LLM cost.
652
+
653
+ ### GitHub Actions
654
+
655
+ ```yaml
656
+ name: Agent Tests
657
+ on: [push, pull_request]
658
+
659
+ jobs:
660
+ test:
661
+ runs-on: ubuntu-latest
662
+ steps:
663
+ - uses: actions/checkout@v5
664
+ - uses: actions/setup-python@v6
665
+ with:
666
+ python-version: "3.14"
667
+ # Shown separately here for clarity — in practice, list agentverify
668
+ # alongside the rest of your project's deps in requirements.txt.
669
+ - run: pip install "agentverify[all]"
670
+ - run: pip install -r requirements.txt # your project's deps
671
+ - run: pytest --tb=short -v
672
+ ```
673
+
674
+ Cassette files in `tests/cassettes/` are replayed automatically — no API keys or secrets needed in CI.
675
+
676
+ ## Error Messages
677
+
678
+ Clear, structured output when assertions fail:
679
+
680
+ ```
681
+ ToolCallSequenceError: Tool call sequence mismatch at index 1
682
+
683
+ Expected:
684
+ [0] get_location(city="Tokyo")
685
+ [1] get_news(topic="weather") ← first mismatch
686
+
687
+ Actual:
688
+ [0] get_location(city="Tokyo")
689
+ [1] search_web(query="Tokyo weather") ← actual
690
+ ```
691
+
692
+ ```
693
+ CostBudgetError: Token budget exceeded
694
+
695
+ Actual: 1,100 tokens
696
+ Limit: 1,000 tokens
697
+ Exceeded by: 100 tokens (10.0%)
698
+ ```
699
+
700
+ ```
701
+ LatencyBudgetError: Latency budget exceeded
702
+
703
+ Actual: 3,450.0 ms
704
+ Limit: 3,000.0 ms
705
+ Exceeded by: 450.0 ms (15.0%)
706
+ ```
707
+
708
+ Other error types follow the same pattern: `SafetyRuleViolationError`, `FinalOutputError`.
709
+
710
+ ## Requirements
711
+
712
+ - Python 3.10+
713
+ - pytest 7+
714
+
715
+ ## Examples
716
+
717
+ The [`examples/`](examples/) directory contains end-to-end examples with real agent frameworks and MCP servers. Each example ships with pre-recorded cassettes — run the tests without any API keys.
718
+
719
+ | Example | Framework | Description |
720
+ |---|---|---|
721
+ | [`langchain-issue-triage`](examples/langchain-issue-triage/) | LangChain + OpenAI | Triages GitHub issues via GitHub MCP, step-level data flow for the discovered issue number |
722
+ | [`langgraph-multi-agent-supervisor`](examples/langgraph-multi-agent-supervisor/) | LangGraph + OpenAI | Research + math multi-agent handoff with running-total data flow |
723
+ | [`strands-weather-forecaster`](examples/strands-weather-forecaster/) | Strands Agents + Bedrock | Two-step ReAct that discovers a forecast URL, then calls it |
724
+ | [`openai-agents-llm-as-a-judge`](examples/openai-agents-llm-as-a-judge/) | OpenAI Agents SDK | Probabilistic generator ↔ evaluator refinement loop, frozen into a deterministic test with feedback-chain data flow |
725
+ | [`custom-converter-python-agent`](examples/custom-converter-python-agent/) | Anthropic SDK (no framework) | Reference for writing a custom converter — a hand-rolled ReAct loop calling the Anthropic Messages API directly |
726
+ | [`mcp-server`](examples/mcp-server/) | — | Mock GitHub MCP server for token-free testing |
727
+
728
+ See each example's README for setup and recording mode details.
729
+
730
+ ## Roadmap
731
+
732
+ We want agentverify to become the way people regression-test agents in CI — tests that live next to the agent code, in git, reviewed in PRs alongside the prompt and tool changes that affect them.
733
+
734
+ - Async support — first-class `asyncio` testing for async agents and tools
735
+ - Responses API cassette adapter — record/replay for OpenAI Agents SDK (Responses API) with end-to-end example
736
+ - Framework adapters for Google ADK and CrewAI — pending async support and stable tool-call APIs from these frameworks
737
+ - Cost estimation from tokens — auto-calculate `total_cost_usd` from token usage and model pricing
738
+ - Eval-framework bridges — compose ragas / deepeval quality scores with agentverify's action assertions in a single test report
739
+
740
+ ## Changelog
741
+
742
+ See [CHANGELOG.md](CHANGELOG.md) for release history.
743
+
744
+ ## License
745
+
746
+ [MIT](LICENSE)
747
+
748
+ ## Contributing
749
+
750
+ Contributions welcome. Please open an issue first to discuss what you'd like to change. See [CONTRIBUTING.md](CONTRIBUTING.md) for commit, CHANGELOG, and coverage conventions.
751
+
752
+ Development setup:
753
+
754
+ ```bash
755
+ git clone https://github.com/simukappu/agentverify.git
756
+ cd agentverify
757
+ python -m venv .venv && source .venv/bin/activate
758
+ pip install -e ".[dev]"
759
+ pytest
760
+ ```