agentverify 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,42 @@
1
+ """agentverify — deterministic testing for AI agent tool call sequences.
2
+
3
+ Public API re-exports for convenient access::
4
+
5
+ from agentverify import (
6
+ ToolCall, ExecutionResult, TokenUsage,
7
+ ANY, OrderMode,
8
+ assert_tool_calls, assert_cost, assert_no_tool_call,
9
+ assert_final_output, assert_all,
10
+ LLMCassetteRecorder, CassetteMode,
11
+ )
12
+ """
13
+
14
+ from agentverify.assertions import (
15
+ assert_all,
16
+ assert_cost,
17
+ assert_final_output,
18
+ assert_no_tool_call,
19
+ assert_tool_calls,
20
+ )
21
+ from agentverify.cassette.recorder import CassetteMode, LLMCassetteRecorder
22
+ from agentverify.matchers import ANY, OrderMode
23
+ from agentverify.models import ExecutionResult, TokenUsage, ToolCall
24
+
25
+ __all__ = [
26
+ # Data models
27
+ "ToolCall",
28
+ "ExecutionResult",
29
+ "TokenUsage",
30
+ # Matchers
31
+ "ANY",
32
+ "OrderMode",
33
+ # Assertions
34
+ "assert_tool_calls",
35
+ "assert_cost",
36
+ "assert_no_tool_call",
37
+ "assert_final_output",
38
+ "assert_all",
39
+ # Cassette
40
+ "LLMCassetteRecorder",
41
+ "CassetteMode",
42
+ ]
@@ -0,0 +1,324 @@
1
+ """Assertion engine for agentverify.
2
+
3
+ Provides assertion functions for verifying tool call sequences,
4
+ cost/token budgets, safety guardrails, and batch assertion execution.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Callable, Optional
10
+
11
+ from agentverify.errors import (
12
+ AgentVerifyError,
13
+ CostBudgetError,
14
+ FinalOutputError,
15
+ MultipleAssertionError,
16
+ SafetyRuleViolationError,
17
+ ToolCallSequenceError,
18
+ )
19
+ from agentverify.matchers import OrderMode
20
+ from agentverify.models import ExecutionResult, ToolCall
21
+
22
+
23
+ def _tool_call_matches(
24
+ expected: ToolCall,
25
+ actual: ToolCall,
26
+ partial_args: bool,
27
+ ) -> bool:
28
+ """Check whether two ToolCalls match.
29
+
30
+ Names must match exactly. Argument matching depends on *partial_args*:
31
+ - False: arguments must be equal (ANY matcher still works via __eq__)
32
+ - True: only keys present in expected.arguments are checked
33
+ """
34
+ if expected.name != actual.name:
35
+ return False
36
+
37
+ if partial_args:
38
+ for key, value in expected.arguments.items():
39
+ if key not in actual.arguments:
40
+ return False
41
+ if value != actual.arguments[key]:
42
+ return False
43
+ return True
44
+
45
+ return expected.arguments == actual.arguments
46
+
47
+
48
+ def assert_tool_calls(
49
+ result: ExecutionResult,
50
+ expected: list[ToolCall],
51
+ order: OrderMode = OrderMode.EXACT,
52
+ partial_args: bool = False,
53
+ ) -> None:
54
+ """Verify the tool call sequence in an ExecutionResult.
55
+
56
+ Args:
57
+ result: The ExecutionResult to verify.
58
+ expected: Expected ToolCall list.
59
+ order: Comparison mode (EXACT, IN_ORDER, ANY_ORDER).
60
+ partial_args: When True, only keys present in expected arguments
61
+ are checked against actual arguments.
62
+
63
+ Raises:
64
+ ToolCallSequenceError: When a mismatch is detected.
65
+ """
66
+ actual = result.tool_calls
67
+
68
+ if order == OrderMode.EXACT:
69
+ _assert_exact(expected, actual, partial_args)
70
+ elif order == OrderMode.IN_ORDER:
71
+ _assert_in_order(expected, actual, partial_args)
72
+ elif order == OrderMode.ANY_ORDER:
73
+ _assert_any_order(expected, actual, partial_args)
74
+ else: # pragma: no cover — OrderMode is a closed enum
75
+ pass
76
+
77
+
78
+ def _assert_exact(
79
+ expected: list[ToolCall],
80
+ actual: list[ToolCall],
81
+ partial_args: bool,
82
+ ) -> None:
83
+ """EXACT mode: same length and same tool calls at each position."""
84
+ if len(expected) != len(actual):
85
+ # first_mismatch_index is the first position that differs,
86
+ # or min(len(expected), len(actual)) if one list is shorter.
87
+ mismatch = min(len(expected), len(actual))
88
+ for i in range(mismatch):
89
+ if not _tool_call_matches(expected[i], actual[i], partial_args):
90
+ mismatch = i
91
+ break
92
+ raise ToolCallSequenceError(
93
+ expected=expected,
94
+ actual=actual,
95
+ first_mismatch_index=mismatch,
96
+ )
97
+
98
+ for i in range(len(expected)):
99
+ if not _tool_call_matches(expected[i], actual[i], partial_args):
100
+ raise ToolCallSequenceError(
101
+ expected=expected,
102
+ actual=actual,
103
+ first_mismatch_index=i,
104
+ )
105
+
106
+
107
+ def _assert_in_order(
108
+ expected: list[ToolCall],
109
+ actual: list[ToolCall],
110
+ partial_args: bool,
111
+ ) -> None:
112
+ """IN_ORDER mode: expected must be a subsequence of actual.
113
+
114
+ Uses a two-pointer approach: walk through actual looking for each
115
+ expected element in order.
116
+ """
117
+ if not expected:
118
+ return
119
+
120
+ exp_idx = 0
121
+ for act_idx in range(len(actual)):
122
+ if exp_idx < len(expected) and _tool_call_matches(
123
+ expected[exp_idx], actual[act_idx], partial_args
124
+ ):
125
+ exp_idx += 1
126
+
127
+ if exp_idx < len(expected):
128
+ # The first unmatched expected element is the mismatch point
129
+ raise ToolCallSequenceError(
130
+ expected=expected,
131
+ actual=actual,
132
+ first_mismatch_index=exp_idx,
133
+ )
134
+
135
+
136
+ def _assert_any_order(
137
+ expected: list[ToolCall],
138
+ actual: list[ToolCall],
139
+ partial_args: bool,
140
+ ) -> None:
141
+ """ANY_ORDER mode: all expected tool calls must exist in actual."""
142
+ for i, exp_tc in enumerate(expected):
143
+ found = any(
144
+ _tool_call_matches(exp_tc, act_tc, partial_args)
145
+ for act_tc in actual
146
+ )
147
+ if not found:
148
+ raise ToolCallSequenceError(
149
+ expected=expected,
150
+ actual=actual,
151
+ first_mismatch_index=i,
152
+ )
153
+
154
+
155
+ def assert_cost(
156
+ result: ExecutionResult,
157
+ max_tokens: Optional[int] = None,
158
+ max_cost_usd: Optional[float] = None,
159
+ strict: bool = False,
160
+ ) -> None:
161
+ """Verify that cost/token consumption is within budget.
162
+
163
+ Args:
164
+ result: The ExecutionResult to verify.
165
+ max_tokens: Maximum allowed total tokens.
166
+ max_cost_usd: Maximum allowed cost in USD.
167
+ strict: When True, raise CostBudgetError if token_usage or
168
+ total_cost_usd is None when the corresponding limit is set.
169
+ Default False silently passes when data is unavailable.
170
+
171
+ Raises:
172
+ CostBudgetError: When budget is exceeded or when strict=True
173
+ and the required data is missing.
174
+ """
175
+ if max_tokens is not None:
176
+ if result.token_usage is not None:
177
+ actual_tokens = result.token_usage.total_tokens
178
+ if actual_tokens > max_tokens:
179
+ raise CostBudgetError(
180
+ actual=actual_tokens,
181
+ limit=max_tokens,
182
+ exceeded_by=actual_tokens - max_tokens,
183
+ )
184
+ elif strict:
185
+ raise CostBudgetError(
186
+ actual=0,
187
+ limit=max_tokens,
188
+ exceeded_by=0,
189
+ message="token_usage is None but strict mode requires it",
190
+ )
191
+
192
+ if max_cost_usd is not None:
193
+ if result.total_cost_usd is not None:
194
+ if result.total_cost_usd > max_cost_usd:
195
+ raise CostBudgetError(
196
+ actual=result.total_cost_usd,
197
+ limit=max_cost_usd,
198
+ exceeded_by=result.total_cost_usd - max_cost_usd,
199
+ )
200
+ elif strict:
201
+ raise CostBudgetError(
202
+ actual=0.0,
203
+ limit=max_cost_usd,
204
+ exceeded_by=0.0,
205
+ message="total_cost_usd is None but strict mode requires it",
206
+ )
207
+
208
+
209
+ def assert_no_tool_call(
210
+ result: ExecutionResult,
211
+ forbidden_tools: list[str],
212
+ ) -> None:
213
+ """Verify that no forbidden tools were called.
214
+
215
+ All violations are collected and reported at once.
216
+
217
+ Args:
218
+ result: The ExecutionResult to verify.
219
+ forbidden_tools: List of forbidden tool names.
220
+
221
+ Raises:
222
+ SafetyRuleViolationError: When forbidden tool calls are detected.
223
+ """
224
+ forbidden_set = set(forbidden_tools)
225
+ violations: list[dict] = []
226
+
227
+ for position, tc in enumerate(result.tool_calls):
228
+ if tc.name in forbidden_set:
229
+ violations.append(
230
+ {
231
+ "tool_name": tc.name,
232
+ "arguments": tc.arguments,
233
+ "position": position,
234
+ }
235
+ )
236
+
237
+ if violations:
238
+ raise SafetyRuleViolationError(violations=violations)
239
+
240
+
241
+ def assert_final_output(
242
+ result: ExecutionResult,
243
+ contains: Optional[str] = None,
244
+ equals: Optional[str] = None,
245
+ matches: Optional[str] = None,
246
+ ) -> None:
247
+ """Verify the agent's final text output.
248
+
249
+ At least one of *contains*, *equals*, or *matches* must be provided.
250
+
251
+ Args:
252
+ result: The ExecutionResult to verify.
253
+ contains: Substring that must appear in final_output.
254
+ equals: Exact string that final_output must equal.
255
+ matches: Regex pattern that final_output must match.
256
+
257
+ Raises:
258
+ FinalOutputError: When the final output does not meet expectations.
259
+ """
260
+ if contains is None and equals is None and matches is None:
261
+ raise ValueError(
262
+ "assert_final_output requires at least one of: contains, equals, matches"
263
+ )
264
+
265
+ if result.final_output is None:
266
+ raise FinalOutputError("final_output is None")
267
+
268
+ if equals is not None:
269
+ if result.final_output != equals:
270
+ raise FinalOutputError(
271
+ f"final_output does not equal expected\n"
272
+ f"\n"
273
+ f" Expected: {equals!r}\n"
274
+ f" Actual: {result.final_output!r}"
275
+ )
276
+
277
+ if contains is not None:
278
+ if contains not in result.final_output:
279
+ raise FinalOutputError(
280
+ f"final_output does not contain expected substring\n"
281
+ f"\n"
282
+ f" Substring: {contains!r}\n"
283
+ f" Actual: {result.final_output!r}"
284
+ )
285
+
286
+ if matches is not None:
287
+ import re
288
+
289
+ if not re.search(matches, result.final_output):
290
+ raise FinalOutputError(
291
+ f"final_output does not match pattern\n"
292
+ f"\n"
293
+ f" Pattern: {matches!r}\n"
294
+ f" Actual: {result.final_output!r}"
295
+ )
296
+
297
+
298
+ def assert_all(
299
+ result: ExecutionResult,
300
+ *assertions: Callable[[ExecutionResult], None],
301
+ ) -> None:
302
+ """Execute multiple assertions, collecting all failures.
303
+
304
+ Each assertion is a callable that takes an ExecutionResult and
305
+ returns None or raises an AgentVerifyError. All assertions are
306
+ executed even if some fail.
307
+
308
+ Args:
309
+ result: The ExecutionResult to verify.
310
+ *assertions: Callable assertions to execute.
311
+
312
+ Raises:
313
+ MultipleAssertionError: When one or more assertions fail.
314
+ """
315
+ errors: list[AgentVerifyError] = []
316
+
317
+ for assertion in assertions:
318
+ try:
319
+ assertion(result)
320
+ except AgentVerifyError as exc:
321
+ errors.append(exc)
322
+
323
+ if errors:
324
+ raise MultipleAssertionError(errors=errors)
@@ -0,0 +1 @@
1
+
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,274 @@
1
+ """Anthropic SDK adapter for LLM cassette recording and replay.
2
+
3
+ Monkey-patches ``anthropic.resources.messages.Messages.create``
4
+ to intercept message creation calls for recording and replaying via the
5
+ cassette recorder.
6
+
7
+ anthropic is an **optional** dependency. Import this module only when the
8
+ anthropic extra is installed (``pip install agentverify[anthropic]``).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from contextlib import contextmanager
15
+ from typing import TYPE_CHECKING, Any, Generator
16
+ from unittest.mock import patch
17
+
18
+ from agentverify.cassette.adapters.base import (
19
+ LLMProviderAdapter,
20
+ NormalizedRequest,
21
+ NormalizedResponse,
22
+ )
23
+ from agentverify.models import TokenUsage
24
+
25
+ if TYPE_CHECKING:
26
+ from agentverify.cassette.recorder import LLMCassetteRecorder
27
+
28
+ try:
29
+ import anthropic as _anthropic_module # noqa: F401 – presence check
30
+ except ImportError: # pragma: no cover
31
+ _anthropic_module = None # type: ignore[assignment]
32
+
33
+ _PATCH_TARGET = "anthropic.resources.messages.Messages.create"
34
+
35
+
36
+ def _ensure_anthropic_installed() -> None:
37
+ """Raise a clear error when the anthropic package is missing."""
38
+ if _anthropic_module is None: # pragma: no cover
39
+ raise ImportError(
40
+ "The anthropic package is required to use AnthropicAdapter. "
41
+ "Install it with: pip install agentverify[anthropic]"
42
+ )
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Lightweight response objects used by denormalize_response so that callers
47
+ # can access ``response.content``, ``response.usage``, etc. without needing a
48
+ # real ``anthropic.types.Message`` instance.
49
+ # ---------------------------------------------------------------------------
50
+
51
+
52
+ class _Usage:
53
+ """Minimal stand-in for ``anthropic.types.Usage``."""
54
+
55
+ def __init__(self, input_tokens: int, output_tokens: int) -> None:
56
+ self.input_tokens = input_tokens
57
+ self.output_tokens = output_tokens
58
+
59
+
60
+ class _TextBlock:
61
+ """Minimal stand-in for ``anthropic.types.TextBlock``."""
62
+
63
+ def __init__(self, text: str) -> None:
64
+ self.type = "text"
65
+ self.text = text
66
+
67
+
68
+ class _ToolUseBlock:
69
+ """Minimal stand-in for ``anthropic.types.ToolUseBlock``."""
70
+
71
+ def __init__(self, id: str, name: str, input: dict[str, Any]) -> None:
72
+ self.type = "tool_use"
73
+ self.id = id
74
+ self.name = name
75
+ self.input = input
76
+
77
+
78
+ class _Message:
79
+ """Minimal stand-in for ``anthropic.types.Message``."""
80
+
81
+ def __init__(
82
+ self,
83
+ content: list[_TextBlock | _ToolUseBlock],
84
+ usage: _Usage | None,
85
+ stop_reason: str = "end_turn",
86
+ model: str = "",
87
+ id: str = "",
88
+ ) -> None:
89
+ self.content = content
90
+ self.usage = usage
91
+ self.stop_reason = stop_reason
92
+ self.model = model
93
+ self.id = id
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Adapter implementation
98
+ # ---------------------------------------------------------------------------
99
+
100
+
101
+ class AnthropicAdapter(LLMProviderAdapter):
102
+ """Adapter for the Anthropic Python SDK (``anthropic>=0.20``)."""
103
+
104
+ @property
105
+ def name(self) -> str:
106
+ return "anthropic"
107
+
108
+ # -- normalisation -------------------------------------------------------
109
+
110
+ def normalize_request(self, raw_request: dict[str, Any]) -> NormalizedRequest:
111
+ """Convert Anthropic ``messages.create()`` kwargs to *NormalizedRequest*."""
112
+ messages = raw_request.get("messages", [])
113
+ model = raw_request.get("model", "")
114
+
115
+ # Normalise tools: Anthropic format uses
116
+ # {"name": ..., "input_schema": {...}}
117
+ raw_tools = raw_request.get("tools")
118
+ tools: list[dict[str, Any]] | None = None
119
+ if raw_tools is not None:
120
+ tools = []
121
+ for t in raw_tools:
122
+ tools.append(
123
+ {
124
+ "name": t.get("name", ""),
125
+ "parameters": t.get("input_schema", {}),
126
+ }
127
+ )
128
+
129
+ # Everything else goes into parameters
130
+ _reserved = {"messages", "model", "tools"}
131
+ parameters = {k: v for k, v in raw_request.items() if k not in _reserved}
132
+
133
+ return NormalizedRequest(
134
+ messages=messages,
135
+ model=model,
136
+ tools=tools,
137
+ parameters=parameters,
138
+ )
139
+
140
+ def normalize_response(self, raw_response: Any) -> NormalizedResponse:
141
+ """Convert an Anthropic ``Message`` response to *NormalizedResponse*."""
142
+ content: str | None = None
143
+ tool_calls: list[dict[str, Any]] | None = None
144
+
145
+ for block in raw_response.content:
146
+ block_type = getattr(block, "type", None)
147
+ if block_type == "text":
148
+ if content is None:
149
+ content = block.text
150
+ else:
151
+ content += block.text
152
+ elif block_type == "tool_use":
153
+ if tool_calls is None:
154
+ tool_calls = []
155
+ tool_calls.append(
156
+ {
157
+ "name": block.name,
158
+ "arguments": json.dumps(block.input),
159
+ }
160
+ )
161
+
162
+ token_usage: TokenUsage | None = None
163
+ if raw_response.usage is not None:
164
+ token_usage = TokenUsage(
165
+ input_tokens=raw_response.usage.input_tokens,
166
+ output_tokens=raw_response.usage.output_tokens,
167
+ )
168
+
169
+ return NormalizedResponse(
170
+ content=content,
171
+ tool_calls=tool_calls,
172
+ token_usage=token_usage,
173
+ )
174
+
175
+ def denormalize_response(self, normalized: NormalizedResponse) -> Any:
176
+ """Build a lightweight object that mimics an Anthropic ``Message``."""
177
+ content_blocks: list[_TextBlock | _ToolUseBlock] = []
178
+
179
+ if normalized.content is not None:
180
+ content_blocks.append(_TextBlock(text=normalized.content))
181
+
182
+ if normalized.tool_calls:
183
+ for i, tc in enumerate(normalized.tool_calls):
184
+ arguments = tc["arguments"]
185
+ if isinstance(arguments, str):
186
+ try:
187
+ arguments = json.loads(arguments)
188
+ except (json.JSONDecodeError, ValueError):
189
+ arguments = {}
190
+ content_blocks.append(
191
+ _ToolUseBlock(
192
+ id=f"toolu_{i}",
193
+ name=tc["name"],
194
+ input=arguments,
195
+ )
196
+ )
197
+
198
+ stop_reason = "tool_use" if normalized.tool_calls else "end_turn"
199
+
200
+ usage: _Usage | None = None
201
+ if normalized.token_usage is not None:
202
+ usage = _Usage(
203
+ input_tokens=normalized.token_usage.input_tokens,
204
+ output_tokens=normalized.token_usage.output_tokens,
205
+ )
206
+
207
+ model = normalized.raw_metadata.get("model", "")
208
+ message_id = normalized.raw_metadata.get("id", "")
209
+
210
+ return _Message(
211
+ content=content_blocks,
212
+ usage=usage,
213
+ stop_reason=stop_reason,
214
+ model=model,
215
+ id=message_id,
216
+ )
217
+
218
+ # -- monkey-patching -----------------------------------------------------
219
+
220
+ @contextmanager
221
+ def patch(self, recorder: LLMCassetteRecorder) -> Generator[None, None, None]:
222
+ """Monkey-patch ``anthropic.resources.messages.Messages.create``.
223
+
224
+ In **record** mode the real API is called, the response is normalised,
225
+ recorded into the cassette, and the original response is returned.
226
+
227
+ In **replay** mode the request is normalised, looked up in the
228
+ cassette, denormalised, and returned without calling the real API.
229
+ """
230
+ _ensure_anthropic_installed()
231
+
232
+ adapter = self
233
+
234
+ def _patched_create(original_fn): # type: ignore[no-untyped-def]
235
+ """Return a wrapper that intercepts ``create()`` calls."""
236
+
237
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
238
+ from agentverify.cassette.recorder import CassetteMode, OnMissingRequest
239
+
240
+ norm_req = adapter.normalize_request(kwargs)
241
+
242
+ if recorder.mode in (CassetteMode.RECORD, CassetteMode.AUTO):
243
+ # Call the real API and record the interaction
244
+ response = original_fn(*args, **kwargs)
245
+ norm_resp = adapter.normalize_response(response)
246
+ recorder.record(norm_req, norm_resp)
247
+ return response
248
+
249
+ # REPLAY mode
250
+ norm_resp = recorder.lookup(norm_req)
251
+ if norm_resp is None:
252
+ if recorder.on_missing == OnMissingRequest.FALLBACK:
253
+ response = original_fn(*args, **kwargs)
254
+ norm_resp = adapter.normalize_response(response)
255
+ recorder.record(norm_req, norm_resp)
256
+ return response
257
+ from agentverify.errors import CassetteMissingRequestError
258
+
259
+ raise CassetteMissingRequestError(
260
+ f"No matching cassette entry for request: model={norm_req.model}"
261
+ )
262
+ return adapter.denormalize_response(norm_resp)
263
+
264
+ return wrapper
265
+
266
+ # Use unittest.mock.patch to ensure clean teardown
267
+ with patch(
268
+ _PATCH_TARGET,
269
+ new=_patched_create(
270
+ # Grab the real function *before* patching
271
+ __import__("anthropic").resources.messages.Messages.create
272
+ ),
273
+ ):
274
+ yield