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.
- agentprdiff/__init__.py +81 -0
- agentprdiff/cli.py +124 -0
- agentprdiff/core.py +217 -0
- agentprdiff/differ.py +161 -0
- agentprdiff/graders/__init__.py +38 -0
- agentprdiff/graders/deterministic.py +186 -0
- agentprdiff/graders/semantic.py +180 -0
- agentprdiff/loader.py +47 -0
- agentprdiff/reporters.py +127 -0
- agentprdiff/runner.py +130 -0
- agentprdiff/store.py +80 -0
- agentprdiff-0.1.0.dist-info/METADATA +200 -0
- agentprdiff-0.1.0.dist-info/RECORD +16 -0
- agentprdiff-0.1.0.dist-info/WHEEL +4 -0
- agentprdiff-0.1.0.dist-info/entry_points.txt +2 -0
- agentprdiff-0.1.0.dist-info/licenses/LICENSE +21 -0
agentprdiff/__init__.py
ADDED
|
@@ -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
|
+
]
|