evalkeep 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.
- evalkeep/__init__.py +12 -0
- evalkeep/__main__.py +6 -0
- evalkeep/adapters/__init__.py +45 -0
- evalkeep/adapters/base.py +92 -0
- evalkeep/adapters/jsonl.py +164 -0
- evalkeep/adapters/langsmith.py +436 -0
- evalkeep/adapters/otlp.py +442 -0
- evalkeep/adapters/semconv.py +208 -0
- evalkeep/analysis.py +174 -0
- evalkeep/analysis_run.py +160 -0
- evalkeep/analyzers/__init__.py +52 -0
- evalkeep/analyzers/anthropic.py +145 -0
- evalkeep/analyzers/stub.py +34 -0
- evalkeep/cache.py +122 -0
- evalkeep/cli.py +1933 -0
- evalkeep/clustering.py +383 -0
- evalkeep/clusters.py +101 -0
- evalkeep/commands/__init__.py +1 -0
- evalkeep/commands/analyze_cmd.py +100 -0
- evalkeep/commands/compare_cmd.py +169 -0
- evalkeep/commands/dataset_cmd.py +182 -0
- evalkeep/commands/detect_cmd.py +154 -0
- evalkeep/commands/discover_cmd.py +274 -0
- evalkeep/commands/ingest_cmd.py +50 -0
- evalkeep/commands/init_cmd.py +151 -0
- evalkeep/commands/pipeline_cmd.py +156 -0
- evalkeep/commands/review_cmd.py +141 -0
- evalkeep/commands/run_cmd.py +131 -0
- evalkeep/commands/target_cmd.py +109 -0
- evalkeep/commands/trace_cmd.py +58 -0
- evalkeep/comparison.py +432 -0
- evalkeep/config.py +209 -0
- evalkeep/detection.py +94 -0
- evalkeep/detectors.py +182 -0
- evalkeep/discovery.py +208 -0
- evalkeep/embeddings/__init__.py +31 -0
- evalkeep/embeddings/base.py +32 -0
- evalkeep/embeddings/hashing.py +98 -0
- evalkeep/errors.py +42 -0
- evalkeep/examples/__init__.py +37 -0
- evalkeep/examples/langsmith/runs.jsonl +18 -0
- evalkeep/examples/opentelemetry/spans.json +898 -0
- evalkeep/examples/refund-agent/agents/baseline.py +66 -0
- evalkeep/examples/refund-agent/agents/candidate.py +66 -0
- evalkeep/examples/refund-agent/traces.jsonl +5 -0
- evalkeep/examples/tau-bench/prepare.py +230 -0
- evalkeep/exporters/__init__.py +45 -0
- evalkeep/exporters/generic.py +31 -0
- evalkeep/exporters/promptfoo.py +219 -0
- evalkeep/failures.py +95 -0
- evalkeep/generation.py +303 -0
- evalkeep/hashing.py +56 -0
- evalkeep/ingest.py +257 -0
- evalkeep/prompts.py +127 -0
- evalkeep/pseudonyms.py +82 -0
- evalkeep/py.typed +0 -0
- evalkeep/redaction.py +333 -0
- evalkeep/regression.py +409 -0
- evalkeep/review.py +309 -0
- evalkeep/runner.py +302 -0
- evalkeep/runs.py +185 -0
- evalkeep/storage/__init__.py +37 -0
- evalkeep/storage/clusters.py +163 -0
- evalkeep/storage/failures.py +254 -0
- evalkeep/storage/migrations.py +370 -0
- evalkeep/storage/regression.py +136 -0
- evalkeep/storage/runs.py +223 -0
- evalkeep/storage/store.py +429 -0
- evalkeep/targets.py +205 -0
- evalkeep/trace.py +238 -0
- evalkeep-0.1.0.dist-info/METADATA +221 -0
- evalkeep-0.1.0.dist-info/RECORD +75 -0
- evalkeep-0.1.0.dist-info/WHEEL +4 -0
- evalkeep-0.1.0.dist-info/entry_points.txt +3 -0
- evalkeep-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Generating a Promptfoo configuration from approved tests.
|
|
2
|
+
|
|
3
|
+
Evalkeep does not execute agents; it hands an established runner a suite and
|
|
4
|
+
reads the results back. That division is the point of the project, so this
|
|
5
|
+
module's only job is translation -- and translation has two rules:
|
|
6
|
+
|
|
7
|
+
* **Every value is embedded as JSON, never interpolated as text.** Expectations
|
|
8
|
+
contain data that came from a trace: order IDs, output fragments, tool names.
|
|
9
|
+
Pasting those into a JavaScript expression by concatenation would be an
|
|
10
|
+
injection bug with the same shape as SQL injection, so every literal goes
|
|
11
|
+
through ``json.dumps``.
|
|
12
|
+
* **Every provider kind is adapted to one response shape.** Assertions are
|
|
13
|
+
written once, against ``{text, toolCalls}``, and the provider configuration is
|
|
14
|
+
what differs between an HTTP endpoint and a local script.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from evalkeep.regression import Expectation, ExpectationType, RegressionTest
|
|
25
|
+
from evalkeep.targets import Target, TargetKind
|
|
26
|
+
|
|
27
|
+
#: Reads a dotted path out of a tool call's arguments, tolerating gaps.
|
|
28
|
+
_ARGUMENT_GETTER = "(p,o)=>p.split('.').reduce((a,k)=>a==null?a:a[k],o)"
|
|
29
|
+
#: Parenthesised deliberately. Without the outer parentheses, `.some()` binds
|
|
30
|
+
#: tighter than `||`, so `(output&&output.toolCalls)||[].some(...)` evaluates to
|
|
31
|
+
#: the tool-call array itself -- truthy -- and every tool assertion passes no
|
|
32
|
+
#: matter what the agent did.
|
|
33
|
+
_CALLS = "((output&&output.toolCalls)||[])"
|
|
34
|
+
_TEXT = "String((output&&output.text)??'')"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_config(
|
|
38
|
+
tests: list[RegressionTest],
|
|
39
|
+
target: Target,
|
|
40
|
+
*,
|
|
41
|
+
description: str | None = None,
|
|
42
|
+
project_root: Path | None = None,
|
|
43
|
+
config_dir: Path | None = None,
|
|
44
|
+
) -> dict[str, Any]:
|
|
45
|
+
"""A complete promptfooconfig document for one target and one suite."""
|
|
46
|
+
return {
|
|
47
|
+
"description": description or f"evalkeep suite against {target.target_id}",
|
|
48
|
+
"providers": [provider_for(target, project_root=project_root, config_dir=config_dir)],
|
|
49
|
+
# The prompt is the test input verbatim: Evalkeep tests an application,
|
|
50
|
+
# not a prompt template.
|
|
51
|
+
"prompts": ["{{input}}"],
|
|
52
|
+
"tests": [build_test_case(test) for test in tests],
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def replay_warnings(tests: list[RegressionTest], target: Target) -> list[str]:
|
|
57
|
+
"""Cases where fixtures were recorded but cannot reach the target.
|
|
58
|
+
|
|
59
|
+
Silence here would be the worst outcome: the run would look like a faithful
|
|
60
|
+
replay while actually re-querying live tools, and the comparison would be
|
|
61
|
+
answering a different question than the one it reports.
|
|
62
|
+
"""
|
|
63
|
+
with_fixtures = [test for test in tests if test.fixtures]
|
|
64
|
+
if not with_fixtures:
|
|
65
|
+
return []
|
|
66
|
+
|
|
67
|
+
if target.kind is TargetKind.HTTP:
|
|
68
|
+
body = json.dumps(target.body)
|
|
69
|
+
if FIXTURES_VAR not in body:
|
|
70
|
+
return [
|
|
71
|
+
f"{len(with_fixtures)} test(s) carry recorded fixtures, but the "
|
|
72
|
+
f"body of target {target.target_id!r} never references "
|
|
73
|
+
f"{{{{{FIXTURES_VAR}}}}}, so the agent will call its real tools "
|
|
74
|
+
"instead of replaying what was recorded."
|
|
75
|
+
]
|
|
76
|
+
elif target.kind is TargetKind.MODEL:
|
|
77
|
+
return [
|
|
78
|
+
f"{len(with_fixtures)} test(s) carry recorded fixtures, which a direct "
|
|
79
|
+
"model provider cannot receive. The model will be asked to choose "
|
|
80
|
+
"tools without the recorded results."
|
|
81
|
+
]
|
|
82
|
+
return []
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def provider_for(
|
|
86
|
+
target: Target,
|
|
87
|
+
*,
|
|
88
|
+
project_root: Path | None = None,
|
|
89
|
+
config_dir: Path | None = None,
|
|
90
|
+
) -> dict[str, Any]:
|
|
91
|
+
"""Translate a target into a Promptfoo provider.
|
|
92
|
+
|
|
93
|
+
Script paths in a target are written relative to the project, because that
|
|
94
|
+
is where a person reading ``targets.yaml`` expects them to be. The runner
|
|
95
|
+
resolves them relative to the generated config instead, and the config is a
|
|
96
|
+
build artifact that can land anywhere -- so the path is rewritten here to
|
|
97
|
+
point at the same file from wherever the config is being written.
|
|
98
|
+
"""
|
|
99
|
+
match target.kind:
|
|
100
|
+
case TargetKind.HTTP:
|
|
101
|
+
return {
|
|
102
|
+
"id": target.url,
|
|
103
|
+
"config": {
|
|
104
|
+
"method": target.method,
|
|
105
|
+
"headers": dict(target.headers),
|
|
106
|
+
"body": target.body,
|
|
107
|
+
# Normalize whatever the service returns into the one shape
|
|
108
|
+
# the assertions are written against.
|
|
109
|
+
"transformResponse": (
|
|
110
|
+
"({text: String("
|
|
111
|
+
f"{target.extract.output} ?? ''), toolCalls: "
|
|
112
|
+
f"{target.extract.tool_calls or '[]'} ?? []}})"
|
|
113
|
+
),
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
case TargetKind.PYTHON:
|
|
117
|
+
suffix = f":{target.function}" if target.function else ""
|
|
118
|
+
script = _script_path(target.path, project_root, config_dir)
|
|
119
|
+
return {"id": f"file://{script}{suffix}"}
|
|
120
|
+
case TargetKind.JAVASCRIPT:
|
|
121
|
+
script = _script_path(target.path, project_root, config_dir)
|
|
122
|
+
return {"id": f"file://{script}"}
|
|
123
|
+
case _:
|
|
124
|
+
return {"id": target.provider}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
#: The variable recorded tool results are published under. A target that wants
|
|
128
|
+
#: reproducible replay reads this; one that ignores it behaves as before.
|
|
129
|
+
FIXTURES_VAR = "fixtures"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def build_test_case(test: RegressionTest) -> dict[str, Any]:
|
|
133
|
+
"""One Promptfoo test case, described by its stable test ID.
|
|
134
|
+
|
|
135
|
+
Recorded fixtures are published as a test variable rather than injected.
|
|
136
|
+
Evalkeep cannot intercept a black-box agent's tool calls -- it can only put
|
|
137
|
+
the recorded results where the target can reach them, and say so. What the
|
|
138
|
+
target does with them is the target's decision, and an honest boundary.
|
|
139
|
+
"""
|
|
140
|
+
variables: dict[str, Any] = {"input": _input_text(test)}
|
|
141
|
+
if test.fixtures:
|
|
142
|
+
variables[FIXTURES_VAR] = [fixture.for_replay() for fixture in test.fixtures]
|
|
143
|
+
return {
|
|
144
|
+
"description": test.test_id,
|
|
145
|
+
"vars": variables,
|
|
146
|
+
"assert": [assertion(expectation) for expectation in test.expectations],
|
|
147
|
+
"metadata": {
|
|
148
|
+
"test_id": test.test_id,
|
|
149
|
+
"trace_id": test.provenance.trace_id,
|
|
150
|
+
"failure_type": test.provenance.failure_type,
|
|
151
|
+
"severity": test.provenance.severity,
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def assertion(expectation: Expectation) -> dict[str, Any]:
|
|
157
|
+
"""Translate one expectation into a Promptfoo assertion."""
|
|
158
|
+
if expectation.type is ExpectationType.HUMAN_RUBRIC:
|
|
159
|
+
# The only assertion that needs a model at run time.
|
|
160
|
+
return {"type": "llm-rubric", "value": str(expectation.value)}
|
|
161
|
+
return {"type": "javascript", "value": _javascript(expectation)}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _javascript(expectation: Expectation) -> str:
|
|
165
|
+
value = json.dumps(expectation.value)
|
|
166
|
+
tool = json.dumps(expectation.tool)
|
|
167
|
+
path = json.dumps(expectation.path)
|
|
168
|
+
|
|
169
|
+
match expectation.type:
|
|
170
|
+
case ExpectationType.OUTPUT_CONTAINS:
|
|
171
|
+
return f"{_TEXT}.includes({value})"
|
|
172
|
+
case ExpectationType.OUTPUT_NOT_CONTAINS:
|
|
173
|
+
return f"!{_TEXT}.includes({value})"
|
|
174
|
+
case ExpectationType.OUTPUT_MATCHES:
|
|
175
|
+
return f"new RegExp({value}).test({_TEXT})"
|
|
176
|
+
case ExpectationType.TOOL_CALLED:
|
|
177
|
+
return f"{_CALLS}.some(c=>c.tool==={tool})"
|
|
178
|
+
case ExpectationType.TOOL_NOT_CALLED:
|
|
179
|
+
return f"!{_CALLS}.some(c=>c.tool==={tool})"
|
|
180
|
+
case ExpectationType.TOOL_ARGUMENT_EQUALS:
|
|
181
|
+
return f"{_CALLS}.some(c=>c.tool==={tool}&&{_argument_matches(path, value)})"
|
|
182
|
+
case ExpectationType.TOOL_ARGUMENT_NOT_EQUALS:
|
|
183
|
+
# "Not equals" means no call passed that value, not "some call
|
|
184
|
+
# passed something else" -- one correct call must not excuse a
|
|
185
|
+
# second wrong one.
|
|
186
|
+
return f"!{_CALLS}.some(c=>c.tool==={tool}&&{_argument_matches(path, value)})"
|
|
187
|
+
case ExpectationType.MAX_TOOL_CALLS:
|
|
188
|
+
scope = "true" if expectation.tool is None else f"c.tool==={tool}"
|
|
189
|
+
return f"{_CALLS}.filter(c=>{scope}).length<={int(expectation.value)}"
|
|
190
|
+
case _: # pragma: no cover - every type is handled above
|
|
191
|
+
raise ValueError(f"cannot translate {expectation.type}")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _argument_matches(path: str, value: str) -> str:
|
|
195
|
+
getter = f"({_ARGUMENT_GETTER})({path},c.arguments||{{}})"
|
|
196
|
+
# Compared as JSON so structured argument values behave, and so that 1 and
|
|
197
|
+
# "1" stay different.
|
|
198
|
+
return f"JSON.stringify({getter})===JSON.stringify({value})"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _input_text(test: RegressionTest) -> str:
|
|
202
|
+
if test.input.text:
|
|
203
|
+
return test.input.text
|
|
204
|
+
for message in test.input.messages:
|
|
205
|
+
if message.get("role") == "user":
|
|
206
|
+
return message.get("content", "")
|
|
207
|
+
return test.input.messages[0].get("content", "") if test.input.messages else ""
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _script_path(path: str | None, project_root: Path | None, config_dir: Path | None) -> str:
|
|
211
|
+
"""Rewrite a project-relative script path for the config's own location."""
|
|
212
|
+
if path is None: # pragma: no cover - validate_shape rejects this first
|
|
213
|
+
return ""
|
|
214
|
+
if project_root is None:
|
|
215
|
+
return path
|
|
216
|
+
resolved = (project_root / path).resolve()
|
|
217
|
+
if config_dir is None:
|
|
218
|
+
return str(resolved)
|
|
219
|
+
return os.path.relpath(resolved, config_dir.resolve())
|
evalkeep/failures.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Failure candidates: one per trace, carrying every contributing signal.
|
|
2
|
+
|
|
3
|
+
A failure is the durable record that a trace is worth attention. It holds the
|
|
4
|
+
evidence the detectors found and, once a person has looked, their decision. The
|
|
5
|
+
two are kept apart on purpose: signals are derived and can be recomputed, while
|
|
6
|
+
a review is a human judgement that automated detection must never overwrite.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from enum import StrEnum
|
|
15
|
+
|
|
16
|
+
from evalkeep.detectors import Signal
|
|
17
|
+
|
|
18
|
+
FAILURE_ID_PREFIX = "fail-"
|
|
19
|
+
_FAILURE_ID_LENGTH = 12
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FailureStatus(StrEnum):
|
|
23
|
+
"""Where a failure stands with its reviewer."""
|
|
24
|
+
|
|
25
|
+
#: Detected automatically; nobody has looked yet.
|
|
26
|
+
CANDIDATE = "candidate"
|
|
27
|
+
#: A person confirmed this is a real failure.
|
|
28
|
+
CONFIRMED = "confirmed"
|
|
29
|
+
#: A person decided this is not a failure. Kept for audit, not deleted.
|
|
30
|
+
DISMISSED = "dismissed"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FailureOrigin(StrEnum):
|
|
34
|
+
DETECTOR = "detector"
|
|
35
|
+
MANUAL = "manual"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def failure_id_for(trace_id: str) -> str:
|
|
39
|
+
"""A stable failure ID derived from the trace it belongs to.
|
|
40
|
+
|
|
41
|
+
Detection is idempotent because this is a pure function of the trace ID:
|
|
42
|
+
re-running it addresses the same row rather than creating a second one.
|
|
43
|
+
"""
|
|
44
|
+
digest = hashlib.sha256(trace_id.encode("utf-8")).hexdigest()[:_FAILURE_ID_LENGTH]
|
|
45
|
+
return f"{FAILURE_ID_PREFIX}{digest}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Failure:
|
|
50
|
+
"""One trace's failure record."""
|
|
51
|
+
|
|
52
|
+
failure_id: str
|
|
53
|
+
trace_id: str
|
|
54
|
+
status: FailureStatus = FailureStatus.CANDIDATE
|
|
55
|
+
origin: FailureOrigin = FailureOrigin.DETECTOR
|
|
56
|
+
signals: list[Signal] = field(default_factory=list)
|
|
57
|
+
detected_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
58
|
+
updated_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
59
|
+
reviewer: str | None = None
|
|
60
|
+
reason: str | None = None
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def from_signals(cls, trace_id: str, signals: list[Signal]) -> Failure:
|
|
64
|
+
return cls(failure_id=failure_id_for(trace_id), trace_id=trace_id, signals=list(signals))
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def manual(cls, trace_id: str, *, reviewer: str, reason: str | None = None) -> Failure:
|
|
68
|
+
"""A failure a person added by hand, with no detector evidence."""
|
|
69
|
+
return cls(
|
|
70
|
+
failure_id=failure_id_for(trace_id),
|
|
71
|
+
trace_id=trace_id,
|
|
72
|
+
status=FailureStatus.CONFIRMED,
|
|
73
|
+
origin=FailureOrigin.MANUAL,
|
|
74
|
+
reviewer=reviewer,
|
|
75
|
+
reason=reason,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def reviewed(self) -> bool:
|
|
80
|
+
"""True once a person has decided, which detection must not undo."""
|
|
81
|
+
return self.status is not FailureStatus.CANDIDATE
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def kinds(self) -> list[str]:
|
|
85
|
+
"""The distinct evidence kinds behind this failure, in order."""
|
|
86
|
+
seen: dict[str, None] = {}
|
|
87
|
+
for signal in self.signals:
|
|
88
|
+
seen.setdefault(signal.kind.value, None)
|
|
89
|
+
return list(seen)
|
|
90
|
+
|
|
91
|
+
def review(self, status: FailureStatus, *, reviewer: str, reason: str | None) -> None:
|
|
92
|
+
self.status = status
|
|
93
|
+
self.reviewer = reviewer
|
|
94
|
+
self.reason = reason
|
|
95
|
+
self.updated_at = datetime.now(UTC)
|
evalkeep/generation.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Turning a reviewed failure into a draft regression test.
|
|
2
|
+
|
|
3
|
+
Every expectation here is read off the recorded trace. Nothing is inferred about
|
|
4
|
+
what the agent *should* have done, because a trace does not contain that: it
|
|
5
|
+
shows an action and a verdict, not an intention. So the generator writes the
|
|
6
|
+
half it can defend -- "you called refund_order with order-A and that was wrong"
|
|
7
|
+
-- and records, as a warning, that a reviewer still owes the other half.
|
|
8
|
+
|
|
9
|
+
That is why drafts are the only thing this module produces.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections import Counter
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from evalkeep.analysis import FailureAnalysis, FailureType
|
|
18
|
+
from evalkeep.failures import Failure
|
|
19
|
+
from evalkeep.hashing import content_hash
|
|
20
|
+
from evalkeep.regression import (
|
|
21
|
+
CaseInput,
|
|
22
|
+
Expectation,
|
|
23
|
+
ExpectationType,
|
|
24
|
+
Fixture,
|
|
25
|
+
Provenance,
|
|
26
|
+
RegressionTest,
|
|
27
|
+
ReviewStatus,
|
|
28
|
+
find_contradictions,
|
|
29
|
+
make_test_id,
|
|
30
|
+
validate_expectation,
|
|
31
|
+
)
|
|
32
|
+
from evalkeep.trace import NormalizedTrace, ToolCallEvent, ToolResultEvent
|
|
33
|
+
|
|
34
|
+
#: Bumped when generation would produce different tests from the same input.
|
|
35
|
+
GENERATOR_VERSION = 1
|
|
36
|
+
|
|
37
|
+
#: How deep to flatten nested tool arguments into dotted paths.
|
|
38
|
+
MAX_ARGUMENT_DEPTH = 3
|
|
39
|
+
|
|
40
|
+
NO_POSITIVE_EXPECTATION = (
|
|
41
|
+
"No positive expectation: this test forbids the observed mistake but does "
|
|
42
|
+
"not say what the agent should do instead. Add one before approving."
|
|
43
|
+
)
|
|
44
|
+
UNDESCRIBED_FAILURE = (
|
|
45
|
+
"This failure has not been described, so the test forbids the last action "
|
|
46
|
+
"the agent took without knowing whether that was the relevant mistake. "
|
|
47
|
+
"Describe it with 'evalkeep failures label' for a sharper test."
|
|
48
|
+
)
|
|
49
|
+
NO_TOOL_CALLS = (
|
|
50
|
+
"The failure is about tool use but the trace records no tool calls, so no "
|
|
51
|
+
"tool expectation could be derived."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_test(
|
|
56
|
+
trace: NormalizedTrace,
|
|
57
|
+
failure: Failure,
|
|
58
|
+
analysis: FailureAnalysis | None,
|
|
59
|
+
*,
|
|
60
|
+
cluster_id: str | None = None,
|
|
61
|
+
cluster_label: str | None = None,
|
|
62
|
+
representative_roles: list[str] | None = None,
|
|
63
|
+
) -> RegressionTest:
|
|
64
|
+
"""Generate one pending draft from one analyzed failure."""
|
|
65
|
+
expectations, warnings = derive_expectations(trace, analysis)
|
|
66
|
+
|
|
67
|
+
for expectation in expectations:
|
|
68
|
+
problem = validate_expectation(expectation)
|
|
69
|
+
if problem is not None: # pragma: no cover - generation should not emit these
|
|
70
|
+
warnings.append(f"Invalid expectation ({expectation.describe()}): {problem}")
|
|
71
|
+
|
|
72
|
+
# A contradictory draft fails on a correct agent too, so it is caught here
|
|
73
|
+
# rather than surfacing later as a phantom regression.
|
|
74
|
+
for contradiction in find_contradictions(expectations):
|
|
75
|
+
warnings.append(f"Contradictory expectations: {contradiction.describe()}")
|
|
76
|
+
|
|
77
|
+
if not any(expectation.positive for expectation in expectations):
|
|
78
|
+
warnings.append(NO_POSITIVE_EXPECTATION)
|
|
79
|
+
|
|
80
|
+
return RegressionTest(
|
|
81
|
+
test_id=make_test_id(trace.trace_id, _input_text(trace)),
|
|
82
|
+
failure_id=failure.failure_id,
|
|
83
|
+
status=ReviewStatus.DRAFT,
|
|
84
|
+
input=_build_input(trace),
|
|
85
|
+
fixtures=_build_fixtures(trace),
|
|
86
|
+
expectations=expectations,
|
|
87
|
+
warnings=warnings,
|
|
88
|
+
provenance=Provenance(
|
|
89
|
+
trace_id=trace.trace_id,
|
|
90
|
+
failure_id=failure.failure_id,
|
|
91
|
+
content_hash=content_hash(trace),
|
|
92
|
+
cluster_id=cluster_id,
|
|
93
|
+
cluster_label=cluster_label,
|
|
94
|
+
representative_roles=list(representative_roles or []),
|
|
95
|
+
failure_type=analysis.failure_type.value if analysis else None,
|
|
96
|
+
severity=analysis.severity.value if analysis else None,
|
|
97
|
+
analyzer=analysis.analyzer if analysis else None,
|
|
98
|
+
analysis_summary=analysis.summary if analysis else None,
|
|
99
|
+
evidence=[signal.kind.value for signal in failure.signals],
|
|
100
|
+
generator_version=GENERATOR_VERSION,
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def derive_expectations(
|
|
106
|
+
trace: NormalizedTrace, analysis: FailureAnalysis | None
|
|
107
|
+
) -> tuple[list[Expectation], list[str]]:
|
|
108
|
+
"""Read expectations off the trace, deterministic ones first."""
|
|
109
|
+
calls = trace.tool_calls
|
|
110
|
+
warnings: list[str] = []
|
|
111
|
+
expectations: list[Expectation] = []
|
|
112
|
+
|
|
113
|
+
if analysis is None:
|
|
114
|
+
# Nobody has said what kind of failure this is, but detection found
|
|
115
|
+
# evidence that it *is* one, and the trace still shows exactly what the
|
|
116
|
+
# agent did. Forbidding that specific action needs no diagnosis -- it is
|
|
117
|
+
# the same forbidding half a described failure gets, minus the
|
|
118
|
+
# confidence that this was the relevant mistake.
|
|
119
|
+
warnings.append(UNDESCRIBED_FAILURE)
|
|
120
|
+
expectations += _forbid_observed_arguments(calls, warnings)
|
|
121
|
+
if not expectations:
|
|
122
|
+
expectations.append(
|
|
123
|
+
Expectation(
|
|
124
|
+
type=ExpectationType.HUMAN_RUBRIC,
|
|
125
|
+
value=_rubric_from_evidence(failure_evidence(trace)),
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
return expectations, warnings
|
|
129
|
+
|
|
130
|
+
match analysis.failure_type:
|
|
131
|
+
case FailureType.WRONG_TOOL_ARGUMENT:
|
|
132
|
+
expectations += _forbid_observed_arguments(calls, warnings)
|
|
133
|
+
case FailureType.WRONG_TOOL_SELECTION:
|
|
134
|
+
expectations += _forbid_observed_tool(calls, warnings)
|
|
135
|
+
case FailureType.UNNECESSARY_ACTION:
|
|
136
|
+
expectations += _limit_repeated_calls(calls, warnings)
|
|
137
|
+
case _:
|
|
138
|
+
warnings.append(
|
|
139
|
+
f"No deterministic expectation can be derived from a "
|
|
140
|
+
f"{analysis.failure_type.value} failure; it needs a reviewer's check."
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if not expectations:
|
|
144
|
+
# The rubric is a last resort: it costs an LLM judge at run time, so it
|
|
145
|
+
# only appears where nothing checkable could be read off the trace.
|
|
146
|
+
expectations.append(
|
|
147
|
+
Expectation(
|
|
148
|
+
type=ExpectationType.HUMAN_RUBRIC,
|
|
149
|
+
value=f"The agent must not repeat this failure: {analysis.summary}",
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
return expectations, warnings
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def failure_evidence(trace: NormalizedTrace) -> str:
|
|
156
|
+
"""What the trace itself says went wrong, for an undescribed failure."""
|
|
157
|
+
feedback = trace.outcome.feedback
|
|
158
|
+
if feedback is not None and feedback.comment:
|
|
159
|
+
return feedback.comment
|
|
160
|
+
for evaluation in trace.outcome.evaluations:
|
|
161
|
+
if evaluation.passed is False and evaluation.reason:
|
|
162
|
+
return evaluation.reason
|
|
163
|
+
return "the interaction was recorded as a failure"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _rubric_from_evidence(evidence: str) -> str:
|
|
167
|
+
return f"The agent must not repeat this failure: {evidence}"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _forbid_observed_arguments(
|
|
171
|
+
calls: list[ToolCallEvent], warnings: list[str]
|
|
172
|
+
) -> list[Expectation]:
|
|
173
|
+
"""The last call is the action the outcome is about.
|
|
174
|
+
|
|
175
|
+
A heuristic, and a documented one: earlier calls in a trace are usually
|
|
176
|
+
lookups that succeeded, and asserting against their arguments would forbid
|
|
177
|
+
correct behaviour. The reviewer can widen this during review.
|
|
178
|
+
"""
|
|
179
|
+
if not calls:
|
|
180
|
+
warnings.append(NO_TOOL_CALLS)
|
|
181
|
+
return []
|
|
182
|
+
|
|
183
|
+
implicated = calls[-1]
|
|
184
|
+
arguments = {
|
|
185
|
+
path: value
|
|
186
|
+
for path, value in _flatten(implicated.arguments).items()
|
|
187
|
+
if not _is_prose(value)
|
|
188
|
+
}
|
|
189
|
+
if not arguments:
|
|
190
|
+
# Every argument was free-form prose. "summary != '<300 words>'" is
|
|
191
|
+
# satisfied by any rewording, so it is a check that cannot fail --
|
|
192
|
+
# worse than no check, because it looks like coverage. Forbid the call
|
|
193
|
+
# itself instead: on a trace like an unnecessary escalation, making it
|
|
194
|
+
# at all is the mistake, not the wording of its argument.
|
|
195
|
+
warnings.append(
|
|
196
|
+
f"{implicated.tool} was called with no argument specific enough to assert "
|
|
197
|
+
f"against, so the test forbids the call itself. Narrow it at review if the "
|
|
198
|
+
f"call was right and only its arguments were wrong."
|
|
199
|
+
)
|
|
200
|
+
return _forbid_observed_tool(calls, [])
|
|
201
|
+
if len(calls) > 1:
|
|
202
|
+
warnings.append(
|
|
203
|
+
f"Assertions target the last tool call ({implicated.tool}); "
|
|
204
|
+
f"{len(calls) - 1} earlier call(s) were treated as context."
|
|
205
|
+
)
|
|
206
|
+
return [
|
|
207
|
+
Expectation(
|
|
208
|
+
type=ExpectationType.TOOL_ARGUMENT_NOT_EQUALS,
|
|
209
|
+
tool=implicated.tool,
|
|
210
|
+
path=path,
|
|
211
|
+
value=value,
|
|
212
|
+
)
|
|
213
|
+
for path, value in arguments.items()
|
|
214
|
+
]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
#: Longer than any identifier, code, enum or amount an argument carries, and
|
|
218
|
+
#: about where a value stops being something a second run could reproduce.
|
|
219
|
+
_PROSE_LENGTH = 80
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _is_prose(value: Any) -> bool:
|
|
223
|
+
"""True for a free-text argument, which no equality check can pin down."""
|
|
224
|
+
return isinstance(value, str) and (len(value) > _PROSE_LENGTH or value.count(" ") > 8)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _forbid_observed_tool(calls: list[ToolCallEvent], warnings: list[str]) -> list[Expectation]:
|
|
228
|
+
if not calls:
|
|
229
|
+
warnings.append(NO_TOOL_CALLS)
|
|
230
|
+
return []
|
|
231
|
+
return [Expectation(type=ExpectationType.TOOL_NOT_CALLED, tool=calls[-1].tool)]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _limit_repeated_calls(calls: list[ToolCallEvent], warnings: list[str]) -> list[Expectation]:
|
|
235
|
+
"""Cap the tool the agent over-used at one fewer than it managed.
|
|
236
|
+
|
|
237
|
+
Deliberately a weak bound. The trace proves N calls was too many; it does
|
|
238
|
+
not prove what the right number is, and picking one would be inventing the
|
|
239
|
+
reviewer's judgement.
|
|
240
|
+
"""
|
|
241
|
+
if not calls:
|
|
242
|
+
warnings.append(NO_TOOL_CALLS)
|
|
243
|
+
return []
|
|
244
|
+
|
|
245
|
+
counts = Counter(call.tool for call in calls)
|
|
246
|
+
tool, count = counts.most_common(1)[0]
|
|
247
|
+
if count == 1:
|
|
248
|
+
return [Expectation(type=ExpectationType.TOOL_NOT_CALLED, tool=tool)]
|
|
249
|
+
|
|
250
|
+
warnings.append(
|
|
251
|
+
f"{tool} was called {count} times; the limit is set to {count - 1}, "
|
|
252
|
+
"which only proves the observed count was too many. Tighten it during review."
|
|
253
|
+
)
|
|
254
|
+
return [Expectation(type=ExpectationType.MAX_TOOL_CALLS, tool=tool, value=count - 1)]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _build_input(trace: NormalizedTrace) -> CaseInput:
|
|
258
|
+
return CaseInput(
|
|
259
|
+
text=trace.input.text,
|
|
260
|
+
messages=[
|
|
261
|
+
{"role": message.role.value, "content": message.content}
|
|
262
|
+
for message in trace.input.messages
|
|
263
|
+
],
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _input_text(trace: NormalizedTrace) -> str:
|
|
268
|
+
if trace.input.text:
|
|
269
|
+
return trace.input.text
|
|
270
|
+
for message in trace.input.messages:
|
|
271
|
+
if message.role.value == "user":
|
|
272
|
+
return message.content
|
|
273
|
+
return trace.input.messages[0].content if trace.input.messages else ""
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _build_fixtures(trace: NormalizedTrace) -> list[Fixture]:
|
|
277
|
+
"""The tool results the original agent saw, paired with the calls that got them."""
|
|
278
|
+
arguments_by_call: dict[str, dict[str, Any]] = {
|
|
279
|
+
call.call_id: call.arguments for call in trace.tool_calls if call.call_id
|
|
280
|
+
}
|
|
281
|
+
return [
|
|
282
|
+
Fixture(
|
|
283
|
+
tool=event.tool,
|
|
284
|
+
arguments=arguments_by_call.get(event.call_id or "", {}),
|
|
285
|
+
result=event.result,
|
|
286
|
+
error=event.error,
|
|
287
|
+
call_id=event.call_id,
|
|
288
|
+
)
|
|
289
|
+
for event in trace.events
|
|
290
|
+
if isinstance(event, ToolResultEvent)
|
|
291
|
+
]
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _flatten(arguments: dict[str, Any], prefix: str = "", depth: int = 0) -> dict[str, Any]:
|
|
295
|
+
"""Scalar arguments as dotted paths, so nested values are still assertable."""
|
|
296
|
+
flat: dict[str, Any] = {}
|
|
297
|
+
for key, value in arguments.items():
|
|
298
|
+
path = f"{prefix}{key}"
|
|
299
|
+
if isinstance(value, dict) and depth < MAX_ARGUMENT_DEPTH:
|
|
300
|
+
flat.update(_flatten(value, prefix=f"{path}.", depth=depth + 1))
|
|
301
|
+
elif isinstance(value, str | int | float | bool) or value is None:
|
|
302
|
+
flat[path] = value
|
|
303
|
+
return flat
|
evalkeep/hashing.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Stable content hashing, for detecting the same interaction twice.
|
|
2
|
+
|
|
3
|
+
Two traces are the same *interaction* when the agent was asked the same thing,
|
|
4
|
+
did the same things and got the same verdict -- regardless of when it was
|
|
5
|
+
recorded, what the exporter called the events, or which run it came from. The
|
|
6
|
+
hash therefore covers what failure analysis actually reads and deliberately
|
|
7
|
+
excludes:
|
|
8
|
+
|
|
9
|
+
* ``trace_id`` -- the question is whether a re-export under a new ID is the same
|
|
10
|
+
interaction, so the ID cannot be part of the answer.
|
|
11
|
+
* ``metadata`` and every ``timestamp`` -- wall-clock time and provenance differ
|
|
12
|
+
between exports of one interaction.
|
|
13
|
+
* ``event_id`` and ``call_id`` -- arbitrary handles, often regenerated per export.
|
|
14
|
+
|
|
15
|
+
Hashing runs on the *redacted* trace, so an identical interaction hashes the same
|
|
16
|
+
whether or not it happened to carry a customer's email address.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from evalkeep.trace import NormalizedTrace
|
|
26
|
+
|
|
27
|
+
HASH_ALGORITHM = "sha256"
|
|
28
|
+
|
|
29
|
+
#: Per-event keys that identify the recording rather than the interaction.
|
|
30
|
+
_VOLATILE_EVENT_KEYS = frozenset({"event_id", "call_id", "timestamp"})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def canonical_content(trace: NormalizedTrace) -> dict[str, Any]:
|
|
34
|
+
"""The subset of a trace that defines the interaction."""
|
|
35
|
+
payload = trace.model_dump(mode="json")
|
|
36
|
+
return {
|
|
37
|
+
"input": payload["input"],
|
|
38
|
+
"output": payload["output"],
|
|
39
|
+
"outcome": payload["outcome"],
|
|
40
|
+
"events": [
|
|
41
|
+
{key: value for key, value in event.items() if key not in _VOLATILE_EVENT_KEYS}
|
|
42
|
+
for event in payload["events"]
|
|
43
|
+
],
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def content_hash(trace: NormalizedTrace) -> str:
|
|
48
|
+
"""A stable ``sha256:<hex>`` fingerprint of the interaction."""
|
|
49
|
+
canonical = json.dumps(
|
|
50
|
+
canonical_content(trace),
|
|
51
|
+
sort_keys=True,
|
|
52
|
+
separators=(",", ":"),
|
|
53
|
+
ensure_ascii=False,
|
|
54
|
+
)
|
|
55
|
+
digest = hashlib.new(HASH_ALGORITHM, canonical.encode("utf-8")).hexdigest()
|
|
56
|
+
return f"{HASH_ALGORITHM}:{digest}"
|