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.
Files changed (75) hide show
  1. evalkeep/__init__.py +12 -0
  2. evalkeep/__main__.py +6 -0
  3. evalkeep/adapters/__init__.py +45 -0
  4. evalkeep/adapters/base.py +92 -0
  5. evalkeep/adapters/jsonl.py +164 -0
  6. evalkeep/adapters/langsmith.py +436 -0
  7. evalkeep/adapters/otlp.py +442 -0
  8. evalkeep/adapters/semconv.py +208 -0
  9. evalkeep/analysis.py +174 -0
  10. evalkeep/analysis_run.py +160 -0
  11. evalkeep/analyzers/__init__.py +52 -0
  12. evalkeep/analyzers/anthropic.py +145 -0
  13. evalkeep/analyzers/stub.py +34 -0
  14. evalkeep/cache.py +122 -0
  15. evalkeep/cli.py +1933 -0
  16. evalkeep/clustering.py +383 -0
  17. evalkeep/clusters.py +101 -0
  18. evalkeep/commands/__init__.py +1 -0
  19. evalkeep/commands/analyze_cmd.py +100 -0
  20. evalkeep/commands/compare_cmd.py +169 -0
  21. evalkeep/commands/dataset_cmd.py +182 -0
  22. evalkeep/commands/detect_cmd.py +154 -0
  23. evalkeep/commands/discover_cmd.py +274 -0
  24. evalkeep/commands/ingest_cmd.py +50 -0
  25. evalkeep/commands/init_cmd.py +151 -0
  26. evalkeep/commands/pipeline_cmd.py +156 -0
  27. evalkeep/commands/review_cmd.py +141 -0
  28. evalkeep/commands/run_cmd.py +131 -0
  29. evalkeep/commands/target_cmd.py +109 -0
  30. evalkeep/commands/trace_cmd.py +58 -0
  31. evalkeep/comparison.py +432 -0
  32. evalkeep/config.py +209 -0
  33. evalkeep/detection.py +94 -0
  34. evalkeep/detectors.py +182 -0
  35. evalkeep/discovery.py +208 -0
  36. evalkeep/embeddings/__init__.py +31 -0
  37. evalkeep/embeddings/base.py +32 -0
  38. evalkeep/embeddings/hashing.py +98 -0
  39. evalkeep/errors.py +42 -0
  40. evalkeep/examples/__init__.py +37 -0
  41. evalkeep/examples/langsmith/runs.jsonl +18 -0
  42. evalkeep/examples/opentelemetry/spans.json +898 -0
  43. evalkeep/examples/refund-agent/agents/baseline.py +66 -0
  44. evalkeep/examples/refund-agent/agents/candidate.py +66 -0
  45. evalkeep/examples/refund-agent/traces.jsonl +5 -0
  46. evalkeep/examples/tau-bench/prepare.py +230 -0
  47. evalkeep/exporters/__init__.py +45 -0
  48. evalkeep/exporters/generic.py +31 -0
  49. evalkeep/exporters/promptfoo.py +219 -0
  50. evalkeep/failures.py +95 -0
  51. evalkeep/generation.py +303 -0
  52. evalkeep/hashing.py +56 -0
  53. evalkeep/ingest.py +257 -0
  54. evalkeep/prompts.py +127 -0
  55. evalkeep/pseudonyms.py +82 -0
  56. evalkeep/py.typed +0 -0
  57. evalkeep/redaction.py +333 -0
  58. evalkeep/regression.py +409 -0
  59. evalkeep/review.py +309 -0
  60. evalkeep/runner.py +302 -0
  61. evalkeep/runs.py +185 -0
  62. evalkeep/storage/__init__.py +37 -0
  63. evalkeep/storage/clusters.py +163 -0
  64. evalkeep/storage/failures.py +254 -0
  65. evalkeep/storage/migrations.py +370 -0
  66. evalkeep/storage/regression.py +136 -0
  67. evalkeep/storage/runs.py +223 -0
  68. evalkeep/storage/store.py +429 -0
  69. evalkeep/targets.py +205 -0
  70. evalkeep/trace.py +238 -0
  71. evalkeep-0.1.0.dist-info/METADATA +221 -0
  72. evalkeep-0.1.0.dist-info/RECORD +75 -0
  73. evalkeep-0.1.0.dist-info/WHEEL +4 -0
  74. evalkeep-0.1.0.dist-info/entry_points.txt +3 -0
  75. 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}"