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.
- {agentprdiff-0.4.0 → agentprdiff-0.5.0}/CHANGELOG.md +88 -0
- {agentprdiff-0.4.0 → agentprdiff-0.5.0}/PKG-INFO +3 -2
- {agentprdiff-0.4.0 → agentprdiff-0.5.0}/README.md +2 -1
- {agentprdiff-0.4.0 → agentprdiff-0.5.0}/pyproject.toml +1 -1
- {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/cli.py +84 -14
- {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/core.py +54 -3
- {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/differ.py +10 -3
- {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/graders/deterministic.py +45 -11
- {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/graders/semantic.py +81 -14
- {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/reporters.py +33 -10
- agentprdiff-0.5.0/src/agentprdiff/runner.py +244 -0
- {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/scaffold.py +9 -9
- {agentprdiff-0.4.0 → agentprdiff-0.5.0}/video-tutorials/customer_support_agent/suites/README.md +4 -4
- agentprdiff-0.4.0/src/agentprdiff/runner.py +0 -131
- {agentprdiff-0.4.0 → agentprdiff-0.5.0}/.gitignore +0 -0
- {agentprdiff-0.4.0 → agentprdiff-0.5.0}/LICENSE +0 -0
- {agentprdiff-0.4.0 → agentprdiff-0.5.0}/examples/quickstart/README.md +0 -0
- {agentprdiff-0.4.0 → agentprdiff-0.5.0}/examples/regression-tour/README.md +0 -0
- {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/__init__.py +0 -0
- {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/adapters/__init__.py +0 -0
- {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/adapters/anthropic.py +0 -0
- {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/adapters/openai.py +0 -0
- {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/adapters/pricing.py +0 -0
- {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/filtering.py +0 -0
- {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/graders/__init__.py +0 -0
- {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/graders/http_judge.py +0 -0
- {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/loader.py +0 -0
- {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/masking.py +0 -0
- {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/store.py +0 -0
- {agentprdiff-0.4.0 → agentprdiff-0.5.0}/src/agentprdiff/trace_store.py +0 -0
- {agentprdiff-0.4.0 → agentprdiff-0.5.0}/studio/README.md +0 -0
- {agentprdiff-0.4.0 → agentprdiff-0.5.0}/studio/backend/README.md +0 -0
- {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.
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
215
|
+
grader_id=id,
|
|
216
|
+
reason=reason,
|
|
217
|
+
metadata=metadata,
|
|
184
218
|
)
|
|
185
219
|
|
|
186
220
|
return _grader
|