agentprdiff 0.4.0__tar.gz → 0.5.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 (33) hide show
  1. {agentprdiff-0.4.0 → agentprdiff-0.5.0}/CHANGELOG.md +88 -0
  2. {agentprdiff-0.4.0 → agentprdiff-0.5.0}/PKG-INFO +3 -2
  3. {agentprdiff-0.4.0 → agentprdiff-0.5.0}/README.md +2 -1
  4. {agentprdiff-0.4.0 → agentprdiff-0.5.0}/pyproject.toml +1 -1
  5. {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/cli.py +84 -14
  6. {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/core.py +54 -3
  7. {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/differ.py +10 -3
  8. {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/graders/deterministic.py +45 -11
  9. {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/graders/semantic.py +81 -14
  10. {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/reporters.py +33 -10
  11. agentprdiff-0.5.0/src/agentprdiff/runner.py +244 -0
  12. {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/scaffold.py +9 -9
  13. {agentprdiff-0.4.0 → agentprdiff-0.5.0}/video-tutorials/customer_support_agent/suites/README.md +4 -4
  14. agentprdiff-0.4.0/src/agentprdiff/runner.py +0 -131
  15. {agentprdiff-0.4.0 → agentprdiff-0.5.0}/.gitignore +0 -0
  16. {agentprdiff-0.4.0 → agentprdiff-0.5.0}/LICENSE +0 -0
  17. {agentprdiff-0.4.0 → agentprdiff-0.5.0}/examples/quickstart/README.md +0 -0
  18. {agentprdiff-0.4.0 → agentprdiff-0.5.0}/examples/regression-tour/README.md +0 -0
  19. {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/__init__.py +0 -0
  20. {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/adapters/__init__.py +0 -0
  21. {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/adapters/anthropic.py +0 -0
  22. {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/adapters/openai.py +0 -0
  23. {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/adapters/pricing.py +0 -0
  24. {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/filtering.py +0 -0
  25. {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/graders/__init__.py +0 -0
  26. {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/graders/http_judge.py +0 -0
  27. {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/loader.py +0 -0
  28. {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/masking.py +0 -0
  29. {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/store.py +0 -0
  30. {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/trace_store.py +0 -0
  31. {agentprdiff-0.4.0 → agentprdiff-0.5.0}/studio/README.md +0 -0
  32. {agentprdiff-0.4.0 → agentprdiff-0.5.0}/studio/backend/README.md +0 -0
  33. {agentprdiff-0.4.0 → agentprdiff-0.5.0}/video-tutorials/customer_support_agent/README.md +0 -0
@@ -8,6 +8,94 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.5.0] — 2026-08-30
12
+
13
+ The "trustworthy green builds" release: strict judging, stable assertion
14
+ identity, frozen baselines, a flakiness guard, parallel execution, async
15
+ agents, and an official GitHub Action that posts the behavioral diff on
16
+ pull requests.
17
+
18
+ ### Added
19
+
20
+ - `async def` agents are supported transparently: `run_agent` resolves
21
+ the returned coroutine (including from inside an already-running event
22
+ loop, e.g. Jupyter — resolved on a dedicated thread there), so async
23
+ LangGraph/LlamaIndex-style pipelines no longer need a sync wrapper.
24
+ - `--concurrency N` on `record` and `check`: executes up to N cases at
25
+ once on a thread pool. Suites are I/O-bound, so wall-clock time drops
26
+ near-linearly; works for sync and async agents, preserves suite order
27
+ in reports, and keeps storage writes and diffing on the calling
28
+ thread. Agents must be safe to call from multiple threads when
29
+ concurrency > 1. Pairs with `--runs`: `--runs 3 --concurrency 8`
30
+ costs 3× but takes roughly 1× wall-clock.
31
+
32
+ - Official GitHub Action (`uses: vnageshwaran-de/agentprdiff@main`): a
33
+ composite action at the repo root that installs agentprdiff, runs
34
+ `check` over your suites, and posts the behavioral diff — assertion
35
+ flips, cost/latency deltas, output diffs, multi-run tallies — as a
36
+ single living comment on the pull request (updated in place per push,
37
+ stdlib-only comment script, graceful fallback to job-log output on
38
+ fork PRs). Inputs cover suites/version/install/root/runs/strict-judge/
39
+ comment/github-token/python-version; output `regressed`. Requires
40
+ agentprdiff ≥ 0.5.0.
41
+ - `record` and `check` now accept multiple suite files
42
+ (`agentprdiff check suites/*.py` works as the docs always implied).
43
+ - `--json-out` now writes every suite's report from the invocation as
44
+ `{"reports": [...]}`; previously each suite overwrote the same file so
45
+ only the last survived. Consumers of the old single-suite envelope
46
+ should read the `reports` list instead.
47
+
48
+ - Multi-run flakiness handling: `agentprdiff check --runs N` executes
49
+ each case N times, and a case passes when at least its
50
+ `min_pass_rate` fraction of attempts fully pass —
51
+ `case(..., min_pass_rate=0.6)` with `--runs 3` tolerates one
52
+ stochastic wobble out of three. Defaults (`--runs 1`,
53
+ `min_pass_rate=1.0`) preserve the exact single-shot behavior. Diffs
54
+ and reports use a representative attempt (the last fully-passing one
55
+ when any exists, otherwise the last), the terminal report shows the
56
+ tally (`2/3 runs passed (required ≥ 60%)`), and `CaseReport` gains
57
+ `runs_total` / `runs_passed` / `min_pass_rate` and a `pass_rate`
58
+ property. `record` always runs once — a baseline is a single
59
+ known-good trace.
60
+
61
+ - `agentprdiff check --strict-judge`: fails the run when any `semantic()`
62
+ grader was judged by `fake_judge` via **silent fallback** (no judge env
63
+ var and no provider API key). A green build whose semantic assertions
64
+ were graded by keyword matching is not a green build. Explicit opt-in
65
+ via `AGENTPRDIFF_JUDGE=fake` still passes. Strict mode will become the
66
+ default in v1.0. Silent-fallback grading is also flagged in
67
+ `GradeResult.metadata["silent_fallback"]` for programmatic use.
68
+ - Stable assertion identity: every grader factory now accepts an optional
69
+ `id=` keyword (e.g. `contains("refund", id="mentions-refund")`). Diffs
70
+ match assertions by `id` when both sides have one, falling back to the
71
+ display name — so renaming an argument or rewording a semantic rubric
72
+ no longer registers as a removed + added assertion (a documented source
73
+ of false regressions). `GradeResult` and `AssertionChange` gain a
74
+ `grader_id` field.
75
+ - Baselines now persist their grader verdicts
76
+ (`trace.metadata["grader_results"]`), recorded at `record` time. `check`
77
+ reads the stored verdicts instead of re-running graders against the
78
+ baseline — which previously meant a paid, nondeterministic LLM-judge
79
+ call against the baseline on every check. Legacy baselines without
80
+ stored verdicts fall back to the old re-run path; re-record to migrate.
81
+ - `cost_lt_usd` flags suspicious passes: when a trace made LLM calls but
82
+ recorded `$0.0000` total cost (usually a model missing from the pricing
83
+ table), the pass is annotated in `reason` and
84
+ `metadata["zero_cost_with_llm_calls"]` instead of silently succeeding.
85
+
86
+ ### Changed
87
+
88
+ - The judge-selection env var is now `AGENTPRDIFF_JUDGE`. The legacy
89
+ `AGENTGUARD_JUDGE` name (from a pre-release name of this project) still
90
+ works and emits a `DeprecationWarning`; it will be removed in v1.0.
91
+ When both are set, `AGENTPRDIFF_JUDGE` wins. The judge banner names
92
+ whichever variable was actually used.
93
+
94
+ ### Fixed
95
+
96
+ - A mypy type error in the review reporter's assertion-pairing code;
97
+ the reporter now pairs assertions by stable `grader_id` when present.
98
+
11
99
  ## [0.4.0] — 2026-08-29
12
100
 
13
101
  Minor release bundling the three feature PRs merged since 0.3.1, plus
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentprdiff
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Guard your LLM agents in CI. Snapshot tests that catch behavioral regressions when models, prompts, or vendors change.
5
5
  Project-URL: Homepage, https://agentprdiff.dev
6
6
  Project-URL: Documentation, https://agentprdiff.dev
@@ -117,7 +117,8 @@ That's the whole product. Five CLI commands (`init`, `record`, `check`, `review`
117
117
  - **10 batteries-included graders** — `contains`, `contains_any`, `regex_match`, `tool_called`, `tool_sequence`, `no_tool_called`, `output_length_lt`, `latency_lt_ms`, `cost_lt_usd`, `semantic` (LLM-as-judge with pluggable backend).
118
118
  - **Baseline store** — JSON files under `.agentprdiff/baselines/`, meant to be **committed**. Reviewers see trace changes in pull requests.
119
119
  - **Diff engine** — per-case `TraceDelta` with assertion pass/fail changes, cost delta, latency delta, tool-sequence changes, and a unified output diff.
120
- - **CI-ready CLI** — exit 1 on regression, `--json-out` for artifact archiving, Rich-formatted terminal output.
120
+ - **CI-ready CLI** — exit 1 on regression, `--json-out` for artifact archiving, Rich-formatted terminal output, `--strict-judge` so a missing judge key can't green-light keyword matching, and `--runs N` + per-case `min_pass_rate` so one stochastic wobble doesn't fail the build.
121
+ - **GitHub Action with PR comments** — `uses: vnageshwaran-de/agentprdiff@main` runs `check` and posts the behavioral diff (assertion flips, cost/latency deltas, output diffs) as a living comment on the pull request. [Docs](https://agentprdiff.dev/scenarios/ci-cd/).
121
122
  - **Zero SDK lock-in** — works with OpenAI, Anthropic, Gemini, Bedrock, LangChain, LangGraph, LlamaIndex, Vercel AI SDK, custom wrappers — if you can wrap your agent in a function, `agentprdiff` can test it.
122
123
  - **One-line SDK adapters** — `with instrument_client(client) as trace:` automatically records every LLM and tool call when you're on the OpenAI Python SDK (sync **or** async — `AsyncOpenAI` is supported by the same context manager) or any OpenAI-compatible provider (Groq / Gemini / OpenRouter / Ollama / vLLM / Together / Fireworks / DeepInfra) or the Anthropic SDK. No manual `Trace` wiring required.
123
124
 
@@ -77,7 +77,8 @@ That's the whole product. Five CLI commands (`init`, `record`, `check`, `review`
77
77
  - **10 batteries-included graders** — `contains`, `contains_any`, `regex_match`, `tool_called`, `tool_sequence`, `no_tool_called`, `output_length_lt`, `latency_lt_ms`, `cost_lt_usd`, `semantic` (LLM-as-judge with pluggable backend).
78
78
  - **Baseline store** — JSON files under `.agentprdiff/baselines/`, meant to be **committed**. Reviewers see trace changes in pull requests.
79
79
  - **Diff engine** — per-case `TraceDelta` with assertion pass/fail changes, cost delta, latency delta, tool-sequence changes, and a unified output diff.
80
- - **CI-ready CLI** — exit 1 on regression, `--json-out` for artifact archiving, Rich-formatted terminal output.
80
+ - **CI-ready CLI** — exit 1 on regression, `--json-out` for artifact archiving, Rich-formatted terminal output, `--strict-judge` so a missing judge key can't green-light keyword matching, and `--runs N` + per-case `min_pass_rate` so one stochastic wobble doesn't fail the build.
81
+ - **GitHub Action with PR comments** — `uses: vnageshwaran-de/agentprdiff@main` runs `check` and posts the behavioral diff (assertion flips, cost/latency deltas, output diffs) as a living comment on the pull request. [Docs](https://agentprdiff.dev/scenarios/ci-cd/).
81
82
  - **Zero SDK lock-in** — works with OpenAI, Anthropic, Gemini, Bedrock, LangChain, LangGraph, LlamaIndex, Vercel AI SDK, custom wrappers — if you can wrap your agent in a function, `agentprdiff` can test it.
82
83
  - **One-line SDK adapters** — `with instrument_client(client) as trace:` automatically records every LLM and tool call when you're on the OpenAI Python SDK (sync **or** async — `AsyncOpenAI` is supported by the same context manager) or any OpenAI-compatible provider (Groq / Gemini / OpenRouter / Ollama / vLLM / Together / Fireworks / DeepInfra) or the Anthropic SDK. No manual `Trace` wiring required.
83
84
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "agentprdiff"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "Guard your LLM agents in CI. Snapshot tests that catch behavioral regressions when models, prompts, or vendors change."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -59,6 +59,17 @@ _LIST_OPTION = click.option(
59
59
  is_flag=True,
60
60
  help="Print suite/case names without running anything, then exit.",
61
61
  )
62
+ _CONCURRENCY_OPTION = click.option(
63
+ "--concurrency",
64
+ type=click.IntRange(min=1),
65
+ default=1,
66
+ show_default=True,
67
+ help=(
68
+ "Execute up to this many cases at once on a thread pool. Agent "
69
+ "suites are I/O-bound, so this cuts wall-clock time near-linearly. "
70
+ "Your agent must be safe to call from multiple threads."
71
+ ),
72
+ )
62
73
 
63
74
 
64
75
  @click.group(help="Snapshot testing for LLM agents.")
@@ -87,26 +98,33 @@ def cmd_init(ctx: click.Context) -> None:
87
98
 
88
99
 
89
100
  @main.command("record")
90
- @click.argument("suite_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
101
+ @click.argument(
102
+ "suite_files",
103
+ nargs=-1,
104
+ required=True,
105
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
106
+ )
91
107
  @click.option("--json-out", type=click.Path(path_type=Path), help="Write JSON report to this path.")
92
108
  @_CASE_OPTION
93
109
  @_SKIP_OPTION
94
110
  @_LIST_OPTION
111
+ @_CONCURRENCY_OPTION
95
112
  @click.pass_context
96
113
  def cmd_record(
97
114
  ctx: click.Context,
98
- suite_file: Path,
115
+ suite_files: tuple[Path, ...],
99
116
  json_out: Path | None,
100
117
  case_patterns: tuple[str, ...],
101
118
  skip_patterns: tuple[str, ...],
102
119
  list_only: bool,
120
+ concurrency: int,
103
121
  ) -> None:
104
- """Run every suite in SUITE_FILE and save each trace as the baseline."""
122
+ """Run every suite in SUITE_FILES and save each trace as the baseline."""
105
123
  store: BaselineStore = ctx.obj["store"]
106
- runner = Runner(store)
124
+ runner = Runner(store, concurrency=concurrency)
107
125
  terminal = TerminalReporter()
108
126
 
109
- suites_all = load_suites(suite_file)
127
+ suites_all = [s for f in suite_files for s in load_suites(f)]
110
128
  if list_only:
111
129
  _print_listing(suites_all)
112
130
  return
@@ -114,21 +132,28 @@ def cmd_record(
114
132
  suites = _select_or_exit(suites_all, case_patterns, skip_patterns)
115
133
 
116
134
  any_error = False
135
+ reports = []
117
136
  for s in suites:
118
137
  report = runner.record(s)
119
138
  terminal.render(report)
120
- if json_out:
121
- JsonReporter().render(report, json_out)
139
+ reports.append(report)
122
140
  # record mode doesn't fail on grader failures, but a literal exception
123
141
  # during execution still warrants a nonzero exit.
124
142
  if any(cr.trace.error for cr in report.case_reports):
125
143
  any_error = True
144
+ if json_out:
145
+ JsonReporter().render_many(reports, json_out)
126
146
 
127
147
  sys.exit(1 if any_error else 0)
128
148
 
129
149
 
130
150
  @main.command("check")
131
- @click.argument("suite_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
151
+ @click.argument(
152
+ "suite_files",
153
+ nargs=-1,
154
+ required=True,
155
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
156
+ )
132
157
  @click.option("--json-out", type=click.Path(path_type=Path), help="Write JSON report to this path.")
133
158
  @_CASE_OPTION
134
159
  @_SKIP_OPTION
@@ -140,22 +165,49 @@ def cmd_record(
140
165
  show_default=True,
141
166
  help="Exit non-zero when regressions are detected.",
142
167
  )
168
+ @click.option(
169
+ "--strict-judge",
170
+ is_flag=True,
171
+ default=False,
172
+ help=(
173
+ "Fail when any semantic() grader silently fell back to fake_judge "
174
+ "(no AGENTPRDIFF_JUDGE and no provider API key set). Recommended in "
175
+ "CI; will become the default in v1.0. Explicit opt-in via "
176
+ "AGENTPRDIFF_JUDGE=fake still passes."
177
+ ),
178
+ )
179
+ @click.option(
180
+ "--runs",
181
+ type=click.IntRange(min=1),
182
+ default=1,
183
+ show_default=True,
184
+ help=(
185
+ "Execute each case this many times; a case passes when at least its "
186
+ "min_pass_rate fraction of attempts fully pass (default 1.0 — all "
187
+ "attempts). The flakiness guard for stochastic agents: e.g. --runs 3 "
188
+ "with case(..., min_pass_rate=0.6) tolerates one wobble out of three."
189
+ ),
190
+ )
191
+ @_CONCURRENCY_OPTION
143
192
  @click.pass_context
144
193
  def cmd_check(
145
194
  ctx: click.Context,
146
- suite_file: Path,
195
+ suite_files: tuple[Path, ...],
147
196
  json_out: Path | None,
148
197
  case_patterns: tuple[str, ...],
149
198
  skip_patterns: tuple[str, ...],
150
199
  list_only: bool,
151
200
  fail_on_regression: bool,
201
+ strict_judge: bool,
202
+ runs: int,
203
+ concurrency: int,
152
204
  ) -> None:
153
- """Run every suite in SUITE_FILE and diff against saved baselines."""
205
+ """Run every suite in SUITE_FILES and diff against saved baselines."""
154
206
  store: BaselineStore = ctx.obj["store"]
155
- runner = Runner(store)
207
+ runner = Runner(store, runs=runs, concurrency=concurrency)
156
208
  terminal = TerminalReporter()
157
209
 
158
- suites_all = load_suites(suite_file)
210
+ suites_all = [s for f in suite_files for s in load_suites(f)]
159
211
  if list_only:
160
212
  _print_listing(suites_all)
161
213
  return
@@ -163,12 +215,30 @@ def cmd_check(
163
215
  suites = _select_or_exit(suites_all, case_patterns, skip_patterns)
164
216
 
165
217
  any_regression = False
218
+ silent_judge_cases: list[str] = []
219
+ reports = []
166
220
  for s in suites:
167
221
  report = runner.check(s)
168
222
  terminal.render(report)
169
- if json_out:
170
- JsonReporter().render(report, json_out)
223
+ reports.append(report)
171
224
  any_regression = any_regression or report.has_regression
225
+ for cr in report.case_reports:
226
+ if any(r.metadata.get("silent_fallback") for r in cr.grader_results):
227
+ silent_judge_cases.append(f"{cr.suite_name}/{cr.case_name}")
228
+ if json_out:
229
+ JsonReporter().render_many(reports, json_out)
230
+
231
+ if strict_judge and silent_judge_cases:
232
+ click.echo(
233
+ "\n--strict-judge: semantic() graders were judged by fake_judge via "
234
+ "silent fallback (no AGENTPRDIFF_JUDGE, no OPENAI_API_KEY/"
235
+ "ANTHROPIC_API_KEY) in:\n "
236
+ + "\n ".join(silent_judge_cases)
237
+ + "\nSet a real judge (AGENTPRDIFF_JUDGE=openai|anthropic plus the "
238
+ "matching API key) or opt in explicitly with AGENTPRDIFF_JUDGE=fake.",
239
+ err=True,
240
+ )
241
+ sys.exit(1)
172
242
 
173
243
  sys.exit(1 if (any_regression and fail_on_regression) else 0)
174
244
 
@@ -11,9 +11,12 @@ and diffed across runs.
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
+ import asyncio
15
+ import inspect
14
16
  import time
15
17
  import uuid
16
18
  from collections.abc import Callable
19
+ from concurrent.futures import ThreadPoolExecutor
17
20
  from datetime import datetime, timezone
18
21
  from typing import Any
19
22
 
@@ -99,6 +102,11 @@ class GradeResult(BaseModel):
99
102
 
100
103
  passed: bool
101
104
  grader_name: str
105
+ # Optional stable identity for baseline matching. When set (via the
106
+ # `id=` argument on grader factories), diffs match assertions by this id
107
+ # instead of the display name, so renaming an argument or rewording a
108
+ # rubric doesn't register as a removed + added assertion.
109
+ grader_id: str | None = None
102
110
  reason: str = ""
103
111
  metadata: dict[str, Any] = Field(default_factory=dict)
104
112
 
@@ -122,14 +130,36 @@ class Case(BaseModel):
122
130
  input: Any
123
131
  expect: list[Grader] = Field(default_factory=list)
124
132
  tags: list[str] = Field(default_factory=list)
133
+ # Minimum fraction of attempts that must fully pass when the runner
134
+ # executes the case more than once (`check --runs N`). 1.0 = every
135
+ # attempt must pass (the single-run behavior). E.g. 0.6 with --runs 3
136
+ # tolerates one stochastic wobble out of three.
137
+ min_pass_rate: float = Field(default=1.0, gt=0.0, le=1.0)
125
138
 
126
139
 
127
140
  # An Agent is any callable `(input) -> (output, Trace)`. If the user's agent
128
141
  # returns only an output, the runner wraps it so latency is captured but the
129
- # returned `Trace` has empty llm_calls / tool_calls.
142
+ # returned `Trace` has empty llm_calls / tool_calls. `async def` agents are
143
+ # supported: the runner resolves the coroutine for you (see `run_agent`).
130
144
  AgentFn = Callable[[Any], Any]
131
145
 
132
146
 
147
+ def _resolve_coroutine(coro: Any) -> Any:
148
+ """Run a coroutine to completion and return its result.
149
+
150
+ Works both from plain sync code (the normal CLI path) and from inside an
151
+ already-running event loop (Jupyter, async test runners), where
152
+ ``asyncio.run`` would raise — there we hand the coroutine to a fresh loop
153
+ on a dedicated thread instead.
154
+ """
155
+ try:
156
+ asyncio.get_running_loop()
157
+ except RuntimeError:
158
+ return asyncio.run(coro)
159
+ with ThreadPoolExecutor(max_workers=1) as pool:
160
+ return pool.submit(asyncio.run, coro).result()
161
+
162
+
133
163
  class Suite(BaseModel):
134
164
  """A named group of cases sharing one agent under test."""
135
165
 
@@ -154,12 +184,28 @@ def suite(name: str, agent: AgentFn, cases: list[Case], description: str = "") -
154
184
  return Suite(name=name, agent=agent, cases=cases, description=description)
155
185
 
156
186
 
157
- def case(name: str, input: Any, expect: list[Grader], tags: list[str] | None = None) -> Case:
187
+ def case(
188
+ name: str,
189
+ input: Any,
190
+ expect: list[Grader],
191
+ tags: list[str] | None = None,
192
+ min_pass_rate: float = 1.0,
193
+ ) -> Case:
158
194
  """Create a Case.
159
195
 
196
+ `min_pass_rate` only matters with `check --runs N` (N > 1): the case
197
+ passes when at least this fraction of attempts fully pass. Defaults to
198
+ 1.0 — every attempt must pass.
199
+
160
200
  >>> c = case("refund", input="I want a refund", expect=[contains("refund")])
161
201
  """
162
- return Case(name=name, input=input, expect=expect or [], tags=tags or [])
202
+ return Case(
203
+ name=name,
204
+ input=input,
205
+ expect=expect or [],
206
+ tags=tags or [],
207
+ min_pass_rate=min_pass_rate,
208
+ )
163
209
 
164
210
 
165
211
  # ---------------------------------------------------------------------------
@@ -179,11 +225,16 @@ def run_agent(
179
225
  If the agent returns a `(output, Trace)` tuple, we use the returned trace
180
226
  and just fill in the metadata we can see from out here (suite/case names).
181
227
  Otherwise we build a minimal trace with latency only.
228
+
229
+ `async def` agents work transparently: the returned coroutine is resolved
230
+ here, so latency covers the full awaited execution.
182
231
  """
183
232
  start = time.perf_counter()
184
233
  trace: Trace
185
234
  try:
186
235
  result = agent(input_value)
236
+ if inspect.iscoroutine(result):
237
+ result = _resolve_coroutine(result)
187
238
  except Exception as exc: # noqa: BLE001 — we want to capture any failure mode
188
239
  elapsed_ms = (time.perf_counter() - start) * 1000.0
189
240
  return Trace(
@@ -23,6 +23,7 @@ from .core import GradeResult, Trace
23
23
 
24
24
  class AssertionChange(BaseModel):
25
25
  grader_name: str
26
+ grader_id: str | None = None
26
27
  baseline_passed: bool | None # None = grader didn't exist in baseline
27
28
  current_passed: bool
28
29
  current_reason: str = ""
@@ -100,16 +101,22 @@ def diff_traces(
100
101
  baseline_error=baseline.error if baseline else None,
101
102
  )
102
103
 
103
- baseline_by_name: dict[str, bool] = {}
104
+ # Assertions are matched baseline<->current by stable grader_id when one
105
+ # was provided (the `id=` argument on grader factories), falling back to
106
+ # the display name. Matching by name alone means renaming an argument or
107
+ # rewording a semantic rubric shows up as a removed + added assertion —
108
+ # a documented source of false regressions.
109
+ baseline_by_key: dict[str, bool] = {}
104
110
  if baseline_results:
105
111
  for r in baseline_results:
106
- baseline_by_name[r.grader_name] = r.passed
112
+ baseline_by_key[r.grader_id or r.grader_name] = r.passed
107
113
 
108
114
  for r in current_results:
109
115
  delta.assertion_changes.append(
110
116
  AssertionChange(
111
117
  grader_name=r.grader_name,
112
- baseline_passed=baseline_by_name.get(r.grader_name),
118
+ grader_id=r.grader_id,
119
+ baseline_passed=baseline_by_key.get(r.grader_id or r.grader_name),
113
120
  current_passed=r.passed,
114
121
  current_reason=r.reason,
115
122
  )
@@ -3,6 +3,12 @@
3
3
  These never call an LLM. Prefer them whenever the assertion can be expressed
4
4
  mechanically; reserve the semantic grader for things you genuinely can't
5
5
  encode as a rule.
6
+
7
+ Every factory accepts an optional ``id=`` keyword: a stable identity used to
8
+ match the assertion against baselines in diffs. Without an id, matching falls
9
+ back to the display name (e.g. ``contains('refund')``), which means changing
10
+ an argument reads as a removed + added assertion. Give long-lived assertions
11
+ an id and rename freely.
6
12
  """
7
13
 
8
14
  from __future__ import annotations
@@ -26,7 +32,7 @@ def _output_str(trace: Trace) -> str:
26
32
  return ""
27
33
 
28
34
 
29
- def contains(substring: str, *, case_sensitive: bool = False) -> Grader:
35
+ def contains(substring: str, *, case_sensitive: bool = False, id: str | None = None) -> Grader:
30
36
  """Pass iff the agent's final output contains `substring`."""
31
37
 
32
38
  def _grader(trace: Trace) -> GradeResult:
@@ -37,6 +43,7 @@ def contains(substring: str, *, case_sensitive: bool = False) -> Grader:
37
43
  return GradeResult(
38
44
  passed=passed,
39
45
  grader_name=f"contains({substring!r})",
46
+ grader_id=id,
40
47
  reason=(
41
48
  f"output {'contains' if passed else 'does not contain'} {substring!r}"
42
49
  ),
@@ -45,7 +52,9 @@ def contains(substring: str, *, case_sensitive: bool = False) -> Grader:
45
52
  return _grader
46
53
 
47
54
 
48
- def contains_any(substrings: Sequence[str], *, case_sensitive: bool = False) -> Grader:
55
+ def contains_any(
56
+ substrings: Sequence[str], *, case_sensitive: bool = False, id: str | None = None
57
+ ) -> Grader:
49
58
  """Pass iff the output contains at least one of the listed substrings."""
50
59
 
51
60
  def _grader(trace: Trace) -> GradeResult:
@@ -56,6 +65,7 @@ def contains_any(substrings: Sequence[str], *, case_sensitive: bool = False) ->
56
65
  return GradeResult(
57
66
  passed=passed,
58
67
  grader_name=f"contains_any({list(substrings)!r})",
68
+ grader_id=id,
59
69
  reason=(
60
70
  f"matched {matched!r}"
61
71
  if passed
@@ -66,7 +76,7 @@ def contains_any(substrings: Sequence[str], *, case_sensitive: bool = False) ->
66
76
  return _grader
67
77
 
68
78
 
69
- def regex_match(pattern: str, *, flags: int = 0) -> Grader:
79
+ def regex_match(pattern: str, *, flags: int = 0, id: str | None = None) -> Grader:
70
80
  """Pass iff `pattern` matches the agent's final output."""
71
81
  compiled = re.compile(pattern, flags=flags)
72
82
 
@@ -77,6 +87,7 @@ def regex_match(pattern: str, *, flags: int = 0) -> Grader:
77
87
  return GradeResult(
78
88
  passed=passed,
79
89
  grader_name=f"regex_match({pattern!r})",
90
+ grader_id=id,
80
91
  reason=(
81
92
  f"matched {m.group(0)!r}" if m else f"no match for {pattern!r}"
82
93
  ),
@@ -85,7 +96,7 @@ def regex_match(pattern: str, *, flags: int = 0) -> Grader:
85
96
  return _grader
86
97
 
87
98
 
88
- def tool_called(name: str, *, min_times: int = 1) -> Grader:
99
+ def tool_called(name: str, *, min_times: int = 1, id: str | None = None) -> Grader:
89
100
  """Pass iff the tool `name` was called at least `min_times` times."""
90
101
 
91
102
  def _grader(trace: Trace) -> GradeResult:
@@ -94,13 +105,14 @@ def tool_called(name: str, *, min_times: int = 1) -> Grader:
94
105
  return GradeResult(
95
106
  passed=passed,
96
107
  grader_name=f"tool_called({name!r}, min_times={min_times})",
108
+ grader_id=id,
97
109
  reason=f"tool {name!r} called {count} time(s), required >= {min_times}",
98
110
  )
99
111
 
100
112
  return _grader
101
113
 
102
114
 
103
- def no_tool_called(name: str) -> Grader:
115
+ def no_tool_called(name: str, *, id: str | None = None) -> Grader:
104
116
  """Pass iff the tool `name` was NOT called."""
105
117
 
106
118
  def _grader(trace: Trace) -> GradeResult:
@@ -109,13 +121,16 @@ def no_tool_called(name: str) -> Grader:
109
121
  return GradeResult(
110
122
  passed=passed,
111
123
  grader_name=f"no_tool_called({name!r})",
124
+ grader_id=id,
112
125
  reason=f"tool {name!r} called {count} time(s); expected 0",
113
126
  )
114
127
 
115
128
  return _grader
116
129
 
117
130
 
118
- def tool_sequence(sequence: Sequence[str], *, strict: bool = False) -> Grader:
131
+ def tool_sequence(
132
+ sequence: Sequence[str], *, strict: bool = False, id: str | None = None
133
+ ) -> Grader:
119
134
  """Pass iff the tool-call sequence matches `sequence`.
120
135
 
121
136
  If `strict=False` (default), `sequence` must appear as a subsequence of
@@ -137,13 +152,14 @@ def tool_sequence(sequence: Sequence[str], *, strict: bool = False) -> Grader:
137
152
  return GradeResult(
138
153
  passed=passed,
139
154
  grader_name=f"tool_sequence({list(sequence)!r}, strict={strict})",
155
+ grader_id=id,
140
156
  reason=f"actual tool sequence: {actual}",
141
157
  )
142
158
 
143
159
  return _grader
144
160
 
145
161
 
146
- def output_length_lt(max_chars: int) -> Grader:
162
+ def output_length_lt(max_chars: int, *, id: str | None = None) -> Grader:
147
163
  """Pass iff the output has fewer than `max_chars` characters."""
148
164
 
149
165
  def _grader(trace: Trace) -> GradeResult:
@@ -152,13 +168,14 @@ def output_length_lt(max_chars: int) -> Grader:
152
168
  return GradeResult(
153
169
  passed=passed,
154
170
  grader_name=f"output_length_lt({max_chars})",
171
+ grader_id=id,
155
172
  reason=f"output length {n} chars, limit {max_chars}",
156
173
  )
157
174
 
158
175
  return _grader
159
176
 
160
177
 
161
- def latency_lt_ms(max_ms: float) -> Grader:
178
+ def latency_lt_ms(max_ms: float, *, id: str | None = None) -> Grader:
162
179
  """Pass iff the trace's total latency is below `max_ms` milliseconds."""
163
180
 
164
181
  def _grader(trace: Trace) -> GradeResult:
@@ -166,21 +183,38 @@ def latency_lt_ms(max_ms: float) -> Grader:
166
183
  return GradeResult(
167
184
  passed=passed,
168
185
  grader_name=f"latency_lt_ms({max_ms})",
186
+ grader_id=id,
169
187
  reason=f"latency {trace.total_latency_ms:.1f} ms, limit {max_ms:.1f} ms",
170
188
  )
171
189
 
172
190
  return _grader
173
191
 
174
192
 
175
- def cost_lt_usd(max_usd: float) -> Grader:
176
- """Pass iff the trace's total cost is below `max_usd` dollars."""
193
+ def cost_lt_usd(max_usd: float, *, id: str | None = None) -> Grader:
194
+ """Pass iff the trace's total cost is below `max_usd` dollars.
195
+
196
+ When the trace made LLM calls but recorded $0.00 total cost, the pass is
197
+ flagged as suspicious in the reason and metadata — the usual cause is a
198
+ model missing from the pricing table, which would otherwise let this
199
+ grader trivially pass forever.
200
+ """
177
201
 
178
202
  def _grader(trace: Trace) -> GradeResult:
179
203
  passed = trace.total_cost_usd < max_usd
204
+ reason = f"cost ${trace.total_cost_usd:.4f}, limit ${max_usd:.4f}"
205
+ metadata: dict[str, object] = {}
206
+ if passed and trace.total_cost_usd == 0.0 and trace.llm_calls:
207
+ metadata["zero_cost_with_llm_calls"] = True
208
+ reason += (
209
+ f" (warning: {len(trace.llm_calls)} LLM call(s) recorded $0.0000 — "
210
+ "pricing may be missing for this model; see register_prices())"
211
+ )
180
212
  return GradeResult(
181
213
  passed=passed,
182
214
  grader_name=f"cost_lt_usd({max_usd})",
183
- reason=f"cost ${trace.total_cost_usd:.4f}, limit ${max_usd:.4f}",
215
+ grader_id=id,
216
+ reason=reason,
217
+ metadata=metadata,
184
218
  )
185
219
 
186
220
  return _grader