agentprdiff 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,81 @@
1
+ """agentprdiff — snapshot testing for LLM agents.
2
+
3
+ The one-happy-path public API:
4
+
5
+ from agentprdiff import suite, case
6
+ from agentprdiff.graders import contains, tool_called, latency_lt_ms, semantic
7
+
8
+ def my_agent(query: str) -> str:
9
+ ...
10
+
11
+ billing_suite = suite(
12
+ name="billing",
13
+ agent=my_agent,
14
+ cases=[
15
+ case(
16
+ name="refund_happy_path",
17
+ input="I want a refund for order #1234",
18
+ expect=[
19
+ contains("refund"),
20
+ tool_called("lookup_order"),
21
+ semantic("agent acknowledges the refund and provides next steps"),
22
+ latency_lt_ms(10_000),
23
+ ],
24
+ ),
25
+ ],
26
+ )
27
+
28
+ Run from the shell::
29
+
30
+ agentprdiff init
31
+ agentprdiff record path/to/my_suite.py # save baselines
32
+ agentprdiff check path/to/my_suite.py # diff against baselines; exit 1 on regression
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from .core import (
38
+ AgentFn,
39
+ Case,
40
+ Grader,
41
+ GradeResult,
42
+ LLMCall,
43
+ Suite,
44
+ ToolCall,
45
+ Trace,
46
+ case,
47
+ run_agent,
48
+ suite,
49
+ )
50
+ from .differ import AssertionChange, TraceDelta, diff_traces
51
+ from .runner import CaseReport, Runner, RunReport
52
+ from .store import BaselineStore
53
+
54
+ __version__ = "0.1.0"
55
+
56
+ __all__ = [
57
+ # core
58
+ "Suite",
59
+ "Case",
60
+ "Trace",
61
+ "LLMCall",
62
+ "ToolCall",
63
+ "Grader",
64
+ "GradeResult",
65
+ "AgentFn",
66
+ "suite",
67
+ "case",
68
+ "run_agent",
69
+ # diffing
70
+ "TraceDelta",
71
+ "AssertionChange",
72
+ "diff_traces",
73
+ # runner
74
+ "Runner",
75
+ "RunReport",
76
+ "CaseReport",
77
+ # storage
78
+ "BaselineStore",
79
+ # version
80
+ "__version__",
81
+ ]
agentprdiff/cli.py ADDED
@@ -0,0 +1,124 @@
1
+ """Command-line interface for agentprdiff.
2
+
3
+ Four subcommands:
4
+
5
+ * `agentprdiff init` — scaffold a .agentprdiff/ directory
6
+ * `agentprdiff record` — record baselines for every suite in a file
7
+ * `agentprdiff check` — compare against baselines; exit 1 on regression
8
+ * `agentprdiff diff` — show the diff for the most recent run of a case
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ import click
18
+
19
+ from .loader import load_suites
20
+ from .reporters import JsonReporter, TerminalReporter
21
+ from .runner import Runner
22
+ from .store import BaselineStore
23
+
24
+
25
+ @click.group(help="Snapshot testing for LLM agents.")
26
+ @click.version_option(package_name="agentprdiff", prog_name="agentprdiff")
27
+ @click.option(
28
+ "--root",
29
+ default=".agentprdiff",
30
+ show_default=True,
31
+ help="Directory where baselines and runs are stored.",
32
+ )
33
+ @click.pass_context
34
+ def main(ctx: click.Context, root: str) -> None:
35
+ ctx.ensure_object(dict)
36
+ ctx.obj["store"] = BaselineStore(root=root)
37
+
38
+
39
+ @main.command("init")
40
+ @click.pass_context
41
+ def cmd_init(ctx: click.Context) -> None:
42
+ """Create the .agentprdiff/ directory and a starter .gitignore."""
43
+ store: BaselineStore = ctx.obj["store"]
44
+ store.ensure_initialized()
45
+ click.echo(f"initialized {store.root}/")
46
+ click.echo(f" baselines: {store.baselines_dir}/ (commit this)")
47
+ click.echo(f" runs: {store.runs_dir}/ (gitignored)")
48
+
49
+
50
+ @main.command("record")
51
+ @click.argument("suite_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
52
+ @click.option("--json-out", type=click.Path(path_type=Path), help="Write JSON report to this path.")
53
+ @click.pass_context
54
+ def cmd_record(ctx: click.Context, suite_file: Path, json_out: Path | None) -> None:
55
+ """Run every suite in SUITE_FILE and save each trace as the baseline."""
56
+ store: BaselineStore = ctx.obj["store"]
57
+ runner = Runner(store)
58
+ terminal = TerminalReporter()
59
+
60
+ suites = load_suites(suite_file)
61
+ any_error = False
62
+ for s in suites:
63
+ report = runner.record(s)
64
+ terminal.render(report)
65
+ if json_out:
66
+ JsonReporter().render(report, json_out)
67
+ # record mode doesn't fail on failures, but a literal exception during
68
+ # execution still warrants a nonzero exit.
69
+ if any(cr.trace.error for cr in report.case_reports):
70
+ any_error = True
71
+
72
+ sys.exit(1 if any_error else 0)
73
+
74
+
75
+ @main.command("check")
76
+ @click.argument("suite_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
77
+ @click.option("--json-out", type=click.Path(path_type=Path), help="Write JSON report to this path.")
78
+ @click.option(
79
+ "--fail-on/--no-fail-on",
80
+ "fail_on_regression",
81
+ default=True,
82
+ show_default=True,
83
+ help="Exit non-zero when regressions are detected.",
84
+ )
85
+ @click.pass_context
86
+ def cmd_check(
87
+ ctx: click.Context,
88
+ suite_file: Path,
89
+ json_out: Path | None,
90
+ fail_on_regression: bool,
91
+ ) -> None:
92
+ """Run every suite in SUITE_FILE and diff against saved baselines."""
93
+ store: BaselineStore = ctx.obj["store"]
94
+ runner = Runner(store)
95
+ terminal = TerminalReporter()
96
+
97
+ any_regression = False
98
+ suites = load_suites(suite_file)
99
+ for s in suites:
100
+ report = runner.check(s)
101
+ terminal.render(report)
102
+ if json_out:
103
+ JsonReporter().render(report, json_out)
104
+ any_regression = any_regression or report.has_regression
105
+
106
+ sys.exit(1 if (any_regression and fail_on_regression) else 0)
107
+
108
+
109
+ @main.command("diff")
110
+ @click.argument("suite_name")
111
+ @click.argument("case_name")
112
+ @click.pass_context
113
+ def cmd_diff(ctx: click.Context, suite_name: str, case_name: str) -> None:
114
+ """Show the saved baseline trace for SUITE_NAME / CASE_NAME as pretty JSON."""
115
+ store: BaselineStore = ctx.obj["store"]
116
+ trace = store.load_baseline(suite_name, case_name)
117
+ if trace is None:
118
+ click.echo(f"no baseline found for {suite_name}/{case_name}", err=True)
119
+ sys.exit(2)
120
+ click.echo(json.dumps(trace.model_dump(mode="json"), indent=2))
121
+
122
+
123
+ if __name__ == "__main__": # pragma: no cover
124
+ main()
agentprdiff/core.py ADDED
@@ -0,0 +1,217 @@
1
+ """Core data model for agentprdiff.
2
+
3
+ A `Suite` is a list of `Case`s. Each case, when run, produces a `Trace`. A
4
+ `Grader` is a callable `(Trace) -> GradeResult` that asserts something about
5
+ the trace. The test result for a case is the logical AND of every grader's
6
+ result.
7
+
8
+ Traces are serializable (JSON via pydantic) so they can be stored as baselines
9
+ and diffed across runs.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import time
15
+ import uuid
16
+ from collections.abc import Callable
17
+ from datetime import datetime, timezone
18
+ from typing import Any
19
+
20
+ from pydantic import BaseModel, ConfigDict, Field
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Trace model — what an agent run produces.
24
+ # ---------------------------------------------------------------------------
25
+
26
+
27
+ class LLMCall(BaseModel):
28
+ """A single model invocation captured during an agent run."""
29
+
30
+ provider: str
31
+ model: str
32
+ input_messages: list[dict[str, Any]] = Field(default_factory=list)
33
+ output_text: str = ""
34
+ tool_calls: list[dict[str, Any]] = Field(default_factory=list)
35
+ prompt_tokens: int = 0
36
+ completion_tokens: int = 0
37
+ cost_usd: float = 0.0
38
+ latency_ms: float = 0.0
39
+ timestamp: str = ""
40
+
41
+
42
+ class ToolCall(BaseModel):
43
+ """A single tool / function invocation captured during an agent run."""
44
+
45
+ name: str
46
+ arguments: dict[str, Any] = Field(default_factory=dict)
47
+ result: Any = None
48
+ latency_ms: float = 0.0
49
+ error: str | None = None
50
+
51
+
52
+ class Trace(BaseModel):
53
+ """Full record of one agent run.
54
+
55
+ A trace is what we record as a baseline and what we diff against on
56
+ subsequent runs. Keep it JSON-serializable.
57
+ """
58
+
59
+ model_config = ConfigDict(extra="allow")
60
+
61
+ case_name: str
62
+ suite_name: str
63
+ input: Any
64
+ output: Any = None
65
+ llm_calls: list[LLMCall] = Field(default_factory=list)
66
+ tool_calls: list[ToolCall] = Field(default_factory=list)
67
+ total_cost_usd: float = 0.0
68
+ total_latency_ms: float = 0.0
69
+ total_prompt_tokens: int = 0
70
+ total_completion_tokens: int = 0
71
+ error: str | None = None
72
+ metadata: dict[str, Any] = Field(default_factory=dict)
73
+
74
+ # A stable id (per-run, not per-case) is handy for telemetry / ci logs.
75
+ run_id: str = Field(default_factory=lambda: uuid.uuid4().hex[:12])
76
+ created_at: str = Field(
77
+ default_factory=lambda: datetime.now(timezone.utc).isoformat(timespec="seconds")
78
+ )
79
+
80
+ def record_llm_call(self, call: LLMCall) -> None:
81
+ self.llm_calls.append(call)
82
+ self.total_cost_usd += call.cost_usd
83
+ self.total_latency_ms += call.latency_ms
84
+ self.total_prompt_tokens += call.prompt_tokens
85
+ self.total_completion_tokens += call.completion_tokens
86
+
87
+ def record_tool_call(self, call: ToolCall) -> None:
88
+ self.tool_calls.append(call)
89
+ self.total_latency_ms += call.latency_ms
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Grader model — what's being asserted about a trace.
94
+ # ---------------------------------------------------------------------------
95
+
96
+
97
+ class GradeResult(BaseModel):
98
+ """The outcome of a single grader on a single trace."""
99
+
100
+ passed: bool
101
+ grader_name: str
102
+ reason: str = ""
103
+ metadata: dict[str, Any] = Field(default_factory=dict)
104
+
105
+
106
+ # A Grader is any callable (Trace) -> GradeResult. We keep it as a simple
107
+ # callable rather than an abstract class so users can pass lambdas.
108
+ Grader = Callable[[Trace], GradeResult]
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # Case and Suite.
113
+ # ---------------------------------------------------------------------------
114
+
115
+
116
+ class Case(BaseModel):
117
+ """One input + the assertions that must hold for the resulting trace."""
118
+
119
+ model_config = ConfigDict(arbitrary_types_allowed=True)
120
+
121
+ name: str
122
+ input: Any
123
+ expect: list[Grader] = Field(default_factory=list)
124
+ tags: list[str] = Field(default_factory=list)
125
+
126
+
127
+ # An Agent is any callable `(input) -> (output, Trace)`. If the user's agent
128
+ # returns only an output, the runner wraps it so latency is captured but the
129
+ # returned `Trace` has empty llm_calls / tool_calls.
130
+ AgentFn = Callable[[Any], Any]
131
+
132
+
133
+ class Suite(BaseModel):
134
+ """A named group of cases sharing one agent under test."""
135
+
136
+ model_config = ConfigDict(arbitrary_types_allowed=True)
137
+
138
+ name: str
139
+ agent: AgentFn
140
+ cases: list[Case]
141
+ description: str = ""
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # Public constructors — keep them short and opinionated.
146
+ # ---------------------------------------------------------------------------
147
+
148
+
149
+ def suite(name: str, agent: AgentFn, cases: list[Case], description: str = "") -> Suite:
150
+ """Create a Suite.
151
+
152
+ >>> s = suite("billing", my_agent, cases=[case(...)])
153
+ """
154
+ return Suite(name=name, agent=agent, cases=cases, description=description)
155
+
156
+
157
+ def case(name: str, input: Any, expect: list[Grader], tags: list[str] | None = None) -> Case:
158
+ """Create a Case.
159
+
160
+ >>> c = case("refund", input="I want a refund", expect=[contains("refund")])
161
+ """
162
+ return Case(name=name, input=input, expect=expect or [], tags=tags or [])
163
+
164
+
165
+ # ---------------------------------------------------------------------------
166
+ # Small helper: run an agent callable and build a Trace.
167
+ # ---------------------------------------------------------------------------
168
+
169
+
170
+ def run_agent(
171
+ agent: AgentFn,
172
+ *,
173
+ suite_name: str,
174
+ case_name: str,
175
+ input_value: Any,
176
+ ) -> Trace:
177
+ """Invoke an agent callable and build a Trace around its execution.
178
+
179
+ If the agent returns a `(output, Trace)` tuple, we use the returned trace
180
+ and just fill in the metadata we can see from out here (suite/case names).
181
+ Otherwise we build a minimal trace with latency only.
182
+ """
183
+ start = time.perf_counter()
184
+ trace: Trace
185
+ try:
186
+ result = agent(input_value)
187
+ except Exception as exc: # noqa: BLE001 — we want to capture any failure mode
188
+ elapsed_ms = (time.perf_counter() - start) * 1000.0
189
+ return Trace(
190
+ suite_name=suite_name,
191
+ case_name=case_name,
192
+ input=input_value,
193
+ output=None,
194
+ error=f"{type(exc).__name__}: {exc}",
195
+ total_latency_ms=elapsed_ms,
196
+ )
197
+ elapsed_ms = (time.perf_counter() - start) * 1000.0
198
+
199
+ if isinstance(result, tuple) and len(result) == 2 and isinstance(result[1], Trace):
200
+ output, trace = result
201
+ trace.suite_name = suite_name
202
+ trace.case_name = case_name
203
+ trace.input = input_value
204
+ trace.output = output
205
+ # If the agent didn't track latency itself, fill in wall time as a
206
+ # floor.
207
+ if trace.total_latency_ms == 0.0:
208
+ trace.total_latency_ms = elapsed_ms
209
+ else:
210
+ trace = Trace(
211
+ suite_name=suite_name,
212
+ case_name=case_name,
213
+ input=input_value,
214
+ output=result,
215
+ total_latency_ms=elapsed_ms,
216
+ )
217
+ return trace
agentprdiff/differ.py ADDED
@@ -0,0 +1,161 @@
1
+ """Trace diffing.
2
+
3
+ Given a baseline `Trace` and a current `Trace`, compute a `TraceDelta` that
4
+ summarizes what changed. The delta is the unit of CI output:
5
+
6
+ * assertion changes (which graders flipped pass->fail or fail->pass)
7
+ * cost / latency / token deltas
8
+ * tool-call sequence changes
9
+ * output change (textual diff)
10
+
11
+ The `regressions` property is the canonical "should CI fail?" signal.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import difflib
17
+ from typing import Any
18
+
19
+ from pydantic import BaseModel, Field
20
+
21
+ from .core import GradeResult, Trace
22
+
23
+
24
+ class AssertionChange(BaseModel):
25
+ grader_name: str
26
+ baseline_passed: bool | None # None = grader didn't exist in baseline
27
+ current_passed: bool
28
+ current_reason: str = ""
29
+
30
+ @property
31
+ def is_regression(self) -> bool:
32
+ """A regression = was passing (or absent), now failing."""
33
+ return (self.baseline_passed is None or self.baseline_passed) and not self.current_passed
34
+
35
+ @property
36
+ def is_improvement(self) -> bool:
37
+ return bool(self.baseline_passed is False and self.current_passed)
38
+
39
+
40
+ class TraceDelta(BaseModel):
41
+ """Summary of the delta between baseline and current trace for one case."""
42
+
43
+ suite_name: str
44
+ case_name: str
45
+
46
+ baseline_exists: bool
47
+ assertion_changes: list[AssertionChange] = Field(default_factory=list)
48
+
49
+ cost_delta_usd: float = 0.0
50
+ latency_delta_ms: float = 0.0
51
+ prompt_tokens_delta: int = 0
52
+ completion_tokens_delta: int = 0
53
+
54
+ tool_sequence_changed: bool = False
55
+ baseline_tool_sequence: list[str] = Field(default_factory=list)
56
+ current_tool_sequence: list[str] = Field(default_factory=list)
57
+
58
+ output_changed: bool = False
59
+ output_diff: str = ""
60
+
61
+ current_error: str | None = None
62
+ baseline_error: str | None = None
63
+
64
+ @property
65
+ def regressions(self) -> list[AssertionChange]:
66
+ return [c for c in self.assertion_changes if c.is_regression]
67
+
68
+ @property
69
+ def improvements(self) -> list[AssertionChange]:
70
+ return [c for c in self.assertion_changes if c.is_improvement]
71
+
72
+ @property
73
+ def has_regression(self) -> bool:
74
+ return bool(self.regressions) or (self.current_error is not None and self.baseline_error is None)
75
+
76
+
77
+ def diff_traces(
78
+ *,
79
+ baseline: Trace | None,
80
+ current: Trace,
81
+ current_results: list[GradeResult],
82
+ baseline_results: list[GradeResult] | None = None,
83
+ ) -> TraceDelta:
84
+ """Build a TraceDelta.
85
+
86
+ `current_results` are the grader results from running the cases's expects
87
+ against the `current` trace.
88
+
89
+ `baseline_results` are optional. If provided, they're used to determine
90
+ per-assertion regressions directly. If omitted, we replay the same grader
91
+ names from `current_results` against the baseline by looking them up by
92
+ `grader_name` in the baseline metadata (not typically populated, so in
93
+ practice you should pass them).
94
+ """
95
+ delta = TraceDelta(
96
+ suite_name=current.suite_name,
97
+ case_name=current.case_name,
98
+ baseline_exists=baseline is not None,
99
+ current_error=current.error,
100
+ baseline_error=baseline.error if baseline else None,
101
+ )
102
+
103
+ baseline_by_name: dict[str, bool] = {}
104
+ if baseline_results:
105
+ for r in baseline_results:
106
+ baseline_by_name[r.grader_name] = r.passed
107
+
108
+ for r in current_results:
109
+ delta.assertion_changes.append(
110
+ AssertionChange(
111
+ grader_name=r.grader_name,
112
+ baseline_passed=baseline_by_name.get(r.grader_name),
113
+ current_passed=r.passed,
114
+ current_reason=r.reason,
115
+ )
116
+ )
117
+
118
+ if baseline is not None:
119
+ delta.cost_delta_usd = current.total_cost_usd - baseline.total_cost_usd
120
+ delta.latency_delta_ms = current.total_latency_ms - baseline.total_latency_ms
121
+ delta.prompt_tokens_delta = (
122
+ current.total_prompt_tokens - baseline.total_prompt_tokens
123
+ )
124
+ delta.completion_tokens_delta = (
125
+ current.total_completion_tokens - baseline.total_completion_tokens
126
+ )
127
+ delta.baseline_tool_sequence = [c.name for c in baseline.tool_calls]
128
+ delta.current_tool_sequence = [c.name for c in current.tool_calls]
129
+ delta.tool_sequence_changed = (
130
+ delta.baseline_tool_sequence != delta.current_tool_sequence
131
+ )
132
+ baseline_out = _to_str(baseline.output)
133
+ current_out = _to_str(current.output)
134
+ if baseline_out != current_out:
135
+ delta.output_changed = True
136
+ delta.output_diff = _unified_diff(baseline_out, current_out)
137
+
138
+ return delta
139
+
140
+
141
+ def _to_str(value: Any) -> str:
142
+ if value is None:
143
+ return ""
144
+ if isinstance(value, str):
145
+ return value
146
+ try:
147
+ return str(value)
148
+ except Exception: # noqa: BLE001
149
+ return ""
150
+
151
+
152
+ def _unified_diff(a: str, b: str, n: int = 3) -> str:
153
+ return "".join(
154
+ difflib.unified_diff(
155
+ a.splitlines(keepends=True),
156
+ b.splitlines(keepends=True),
157
+ fromfile="baseline",
158
+ tofile="current",
159
+ n=n,
160
+ )
161
+ )
@@ -0,0 +1,38 @@
1
+ """Graders — assertions over a `Trace`.
2
+
3
+ Every grader returns a `GradeResult`. Deterministic graders (cheap, free,
4
+ no LLM calls) live in `deterministic.py`. The `semantic` grader uses an
5
+ LLM-as-judge backend — it lives in `semantic.py` and is pluggable.
6
+
7
+ The names exposed from this module form `agentprdiff`'s public assertion API.
8
+ Keep the surface area small and stable.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from .deterministic import (
14
+ contains,
15
+ contains_any,
16
+ cost_lt_usd,
17
+ latency_lt_ms,
18
+ no_tool_called,
19
+ output_length_lt,
20
+ regex_match,
21
+ tool_called,
22
+ tool_sequence,
23
+ )
24
+ from .semantic import fake_judge, semantic
25
+
26
+ __all__ = [
27
+ "contains",
28
+ "contains_any",
29
+ "regex_match",
30
+ "tool_called",
31
+ "tool_sequence",
32
+ "no_tool_called",
33
+ "output_length_lt",
34
+ "latency_lt_ms",
35
+ "cost_lt_usd",
36
+ "semantic",
37
+ "fake_judge",
38
+ ]