grudge-cli 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.
- examples/__init__.py +1 -0
- examples/leaky_agent/__init__.py +1 -0
- examples/leaky_agent/agent.py +250 -0
- examples/leaky_agent/agent_fixed.py +183 -0
- examples/leaky_agent/tools.py +153 -0
- grudge/__init__.py +5 -0
- grudge/__main__.py +6 -0
- grudge/_stubs.py +8 -0
- grudge/admission/__init__.py +6 -0
- grudge/admission/court.py +239 -0
- grudge/admission/verdict.py +26 -0
- grudge/cli/__init__.py +1 -0
- grudge/cli/_common.py +126 -0
- grudge/cli/admit.py +93 -0
- grudge/cli/capture.py +39 -0
- grudge/cli/compile.py +37 -0
- grudge/cli/demo.py +136 -0
- grudge/cli/main.py +54 -0
- grudge/cli/run.py +60 -0
- grudge/cli/scars.py +91 -0
- grudge/cli/shrink.py +108 -0
- grudge/config.py +35 -0
- grudge/demo/__init__.py +1 -0
- grudge/demo/events.py +228 -0
- grudge/demo/orchestrator.py +326 -0
- grudge/demo/server.py +222 -0
- grudge/errors.py +25 -0
- grudge/fuzzer/__init__.py +1 -0
- grudge/fuzzer/fingerprint_guard.py +10 -0
- grudge/fuzzer/mutators.py +10 -0
- grudge/gate/__init__.py +5 -0
- grudge/gate/report.py +125 -0
- grudge/gate/runner.py +323 -0
- grudge/gate/selector.py +10 -0
- grudge/gate/wilson.py +36 -0
- grudge/guard/__init__.py +5 -0
- grudge/guard/compile.py +251 -0
- grudge/guard/import_session.py +106 -0
- grudge/integrations/__init__.py +1 -0
- grudge/integrations/github_comment.py +10 -0
- grudge/integrations/pytest_plugin.py +10 -0
- grudge/llm/__init__.py +6 -0
- grudge/llm/cache.py +92 -0
- grudge/llm/client.py +82 -0
- grudge/locator/__init__.py +1 -0
- grudge/locator/fork.py +10 -0
- grudge/locator/point_of_no_return.py +10 -0
- grudge/predicates/__init__.py +25 -0
- grudge/predicates/call_shape.py +116 -0
- grudge/predicates/compiler.py +116 -0
- grudge/predicates/dsl.py +295 -0
- grudge/predicates/fallback_rules.py +10 -0
- grudge/predicates/ordering.py +33 -0
- grudge/predicates/outcome.py +14 -0
- grudge/predicates/prompts/synthesize.md +15 -0
- grudge/predicates/synthesizer.py +127 -0
- grudge/predicates/taint_flow.py +84 -0
- grudge/recorder/__init__.py +5 -0
- grudge/recorder/adapters/__init__.py +1 -0
- grudge/recorder/adapters/langgraph.py +8 -0
- grudge/recorder/adapters/mcp_proxy.py +8 -0
- grudge/recorder/adapters/raw_sdk.py +159 -0
- grudge/recorder/recorder.py +374 -0
- grudge/recorder/redact.py +105 -0
- grudge/registry/__init__.py +1 -0
- grudge/registry/db.py +10 -0
- grudge/registry/fingerprint.py +10 -0
- grudge/registry/flakiness.py +10 -0
- grudge/registry/retirement.py +10 -0
- grudge/replay/__init__.py +19 -0
- grudge/replay/cassette_player.py +159 -0
- grudge/replay/sandbox.py +340 -0
- grudge/replay/scripted_llm.py +382 -0
- grudge/schema/__init__.py +45 -0
- grudge/schema/cassette.py +206 -0
- grudge/schema/predicate.py +263 -0
- grudge/schema/scar.py +113 -0
- grudge/schema/trace.py +243 -0
- grudge/shrinker/__init__.py +12 -0
- grudge/shrinker/cassette_builder.py +328 -0
- grudge/shrinker/ddmin.py +243 -0
- grudge/shrinker/oracle.py +230 -0
- grudge/shrinker/reducers.py +331 -0
- grudge_cli-0.1.0.dist-info/LICENSE +21 -0
- grudge_cli-0.1.0.dist-info/METADATA +313 -0
- grudge_cli-0.1.0.dist-info/RECORD +89 -0
- grudge_cli-0.1.0.dist-info/WHEEL +5 -0
- grudge_cli-0.1.0.dist-info/entry_points.txt +2 -0
- grudge_cli-0.1.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Phase 1 predicate AST contracts for describing agent behaviour.
|
|
2
|
+
|
|
3
|
+
The models in this module are data contracts only. They describe call shapes,
|
|
4
|
+
ordering, taint flow, and run outcomes for later deterministic checkers; they
|
|
5
|
+
do not evaluate a trace themselves.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from typing import Annotated, Any, Literal, TypeAlias
|
|
13
|
+
|
|
14
|
+
from pydantic import AliasChoices, ConfigDict, Field, model_validator
|
|
15
|
+
|
|
16
|
+
from grudge.schema.cassette import _SchemaModel
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _arguments_text(arguments: dict[str, Any]) -> str:
|
|
20
|
+
"""Render argument matching data deterministically for descriptions."""
|
|
21
|
+
|
|
22
|
+
return json.dumps(
|
|
23
|
+
arguments,
|
|
24
|
+
ensure_ascii=False,
|
|
25
|
+
sort_keys=True,
|
|
26
|
+
separators=(", ", ": "),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CallShape(_SchemaModel):
|
|
31
|
+
"""A tool call shape identified by tool name and matching arguments."""
|
|
32
|
+
|
|
33
|
+
model_config = ConfigDict(
|
|
34
|
+
extra="forbid",
|
|
35
|
+
populate_by_name=True,
|
|
36
|
+
validate_assignment=True,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
kind: Literal["call_shape"] = "call_shape"
|
|
40
|
+
tool: str = Field(min_length=1)
|
|
41
|
+
args: dict[str, Any] = Field(
|
|
42
|
+
default_factory=dict,
|
|
43
|
+
validation_alias=AliasChoices("args", "arguments"),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def describe(self) -> str:
|
|
47
|
+
"""Return a plain-English description of this call shape."""
|
|
48
|
+
|
|
49
|
+
arguments = _arguments_text(self.args)
|
|
50
|
+
return f"Call `{self.tool}` with arguments matching {arguments}."
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Ordering(_SchemaModel):
|
|
54
|
+
"""A relationship requiring one tool call to precede another."""
|
|
55
|
+
|
|
56
|
+
model_config = ConfigDict(
|
|
57
|
+
extra="forbid",
|
|
58
|
+
populate_by_name=True,
|
|
59
|
+
validate_assignment=True,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
kind: Literal["ordering"] = "ordering"
|
|
63
|
+
before: str = Field(
|
|
64
|
+
min_length=1,
|
|
65
|
+
validation_alias=AliasChoices("before", "first"),
|
|
66
|
+
)
|
|
67
|
+
after: str = Field(
|
|
68
|
+
min_length=1,
|
|
69
|
+
validation_alias=AliasChoices("after", "then"),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def describe(self) -> str:
|
|
73
|
+
"""Return a plain-English description of this ordering rule."""
|
|
74
|
+
|
|
75
|
+
return f"Call `{self.before}` before calling `{self.after}`."
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TaintFlow(_SchemaModel):
|
|
79
|
+
"""A flow relationship from a source tool to a sink tool."""
|
|
80
|
+
|
|
81
|
+
model_config = ConfigDict(
|
|
82
|
+
extra="forbid",
|
|
83
|
+
populate_by_name=True,
|
|
84
|
+
validate_assignment=True,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
kind: Literal["taint_flow"] = "taint_flow"
|
|
88
|
+
source: str = Field(min_length=1)
|
|
89
|
+
sink: str = Field(min_length=1)
|
|
90
|
+
source_field: str | None = Field(
|
|
91
|
+
default=None,
|
|
92
|
+
validation_alias=AliasChoices("source_field", "from_field"),
|
|
93
|
+
)
|
|
94
|
+
sink_field: str | None = Field(
|
|
95
|
+
default=None,
|
|
96
|
+
validation_alias=AliasChoices("sink_field", "to_field"),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def describe(self) -> str:
|
|
100
|
+
"""Return a plain-English description of this taint flow."""
|
|
101
|
+
|
|
102
|
+
source = self.source
|
|
103
|
+
sink = self.sink
|
|
104
|
+
if self.source_field is not None:
|
|
105
|
+
source = f"{source}.{self.source_field}"
|
|
106
|
+
if self.sink_field is not None:
|
|
107
|
+
sink = f"{sink}.{self.sink_field}"
|
|
108
|
+
return f"Data from `{source}` must not flow to `{sink}`."
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class FilteredTaintFlow(_SchemaModel):
|
|
112
|
+
"""A taint rule narrowed by filters on its source call's arguments."""
|
|
113
|
+
|
|
114
|
+
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
|
115
|
+
kind: Literal["filtered_taint_flow"] = "filtered_taint_flow"
|
|
116
|
+
flow: TaintFlow
|
|
117
|
+
source_args: dict[str, Any] = Field(default_factory=dict)
|
|
118
|
+
|
|
119
|
+
def describe(self) -> str:
|
|
120
|
+
"""Describe the prohibited data flow and any source-call filter."""
|
|
121
|
+
|
|
122
|
+
description = self.flow.describe()
|
|
123
|
+
if self.source_args:
|
|
124
|
+
conditions = []
|
|
125
|
+
words = {
|
|
126
|
+
"$eq": "equals",
|
|
127
|
+
"$contains": "contains",
|
|
128
|
+
"$under": "is under",
|
|
129
|
+
"$not_under": "is not under",
|
|
130
|
+
}
|
|
131
|
+
for field, value in sorted(self.source_args.items()):
|
|
132
|
+
operator = "equals"
|
|
133
|
+
if isinstance(value, dict) and len(value) == 1:
|
|
134
|
+
key = next(iter(value))
|
|
135
|
+
if key in words:
|
|
136
|
+
operator, value = words[key], value[key]
|
|
137
|
+
conditions.append(
|
|
138
|
+
f"{field} {operator} " + json.dumps(value, ensure_ascii=False)
|
|
139
|
+
)
|
|
140
|
+
description += (
|
|
141
|
+
" The source call must satisfy: " + "; ".join(conditions) + "."
|
|
142
|
+
)
|
|
143
|
+
return description
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class Outcome(_SchemaModel):
|
|
147
|
+
"""The Phase 1 run-outcome data contract used by predicate ASTs.
|
|
148
|
+
|
|
149
|
+
This model records the expected terminal outcome value. It intentionally
|
|
150
|
+
contains no checker or trace-evaluation behaviour; that belongs to a later
|
|
151
|
+
predicate implementation stage.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
model_config = ConfigDict(
|
|
155
|
+
extra="forbid",
|
|
156
|
+
populate_by_name=True,
|
|
157
|
+
validate_assignment=True,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
kind: Literal["outcome"] = "outcome"
|
|
161
|
+
outcome: Literal["ok", "failed"] = Field(
|
|
162
|
+
validation_alias=AliasChoices("outcome", "status", "value"),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def describe(self) -> str:
|
|
166
|
+
"""Return a plain-English description of the expected outcome."""
|
|
167
|
+
|
|
168
|
+
return f"The run must finish with outcome `{self.outcome}`."
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class PredicateSet(_SchemaModel):
|
|
172
|
+
"""A logical combination of predicate AST nodes."""
|
|
173
|
+
|
|
174
|
+
model_config = ConfigDict(
|
|
175
|
+
extra="forbid",
|
|
176
|
+
populate_by_name=True,
|
|
177
|
+
validate_assignment=True,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
kind: Literal["all_of", "any_of", "not"]
|
|
181
|
+
predicates: list[Predicate] = Field(default_factory=list)
|
|
182
|
+
|
|
183
|
+
@model_validator(mode="after")
|
|
184
|
+
def _validate_arity(self) -> PredicateSet:
|
|
185
|
+
"""Ensure each logical operator has the appropriate number of terms."""
|
|
186
|
+
|
|
187
|
+
if self.kind in {"all_of", "any_of"} and not self.predicates:
|
|
188
|
+
raise ValueError(f"{self.kind} requires at least one predicate")
|
|
189
|
+
if self.kind == "not" and len(self.predicates) != 1:
|
|
190
|
+
raise ValueError("not requires exactly one predicate")
|
|
191
|
+
return self
|
|
192
|
+
|
|
193
|
+
@staticmethod
|
|
194
|
+
def _items(
|
|
195
|
+
predicates: tuple[Predicate | Sequence[Predicate], ...],
|
|
196
|
+
) -> list[Predicate]:
|
|
197
|
+
"""Accept either a sequence or variadic predicate arguments."""
|
|
198
|
+
|
|
199
|
+
if (
|
|
200
|
+
len(predicates) == 1
|
|
201
|
+
and isinstance(predicates[0], Sequence)
|
|
202
|
+
and not isinstance(predicates[0], str | bytes)
|
|
203
|
+
):
|
|
204
|
+
return list(predicates[0])
|
|
205
|
+
return list(predicates) # type: ignore[arg-type]
|
|
206
|
+
|
|
207
|
+
@classmethod
|
|
208
|
+
def all_of(
|
|
209
|
+
cls,
|
|
210
|
+
*predicates: Predicate | Sequence[Predicate],
|
|
211
|
+
) -> PredicateSet:
|
|
212
|
+
"""Combine predicates that must all hold."""
|
|
213
|
+
|
|
214
|
+
return cls(kind="all_of", predicates=cls._items(predicates))
|
|
215
|
+
|
|
216
|
+
@classmethod
|
|
217
|
+
def any_of(
|
|
218
|
+
cls,
|
|
219
|
+
*predicates: Predicate | Sequence[Predicate],
|
|
220
|
+
) -> PredicateSet:
|
|
221
|
+
"""Combine predicates where at least one must hold."""
|
|
222
|
+
|
|
223
|
+
return cls(kind="any_of", predicates=cls._items(predicates))
|
|
224
|
+
|
|
225
|
+
@classmethod
|
|
226
|
+
def not_(cls, predicate: Predicate) -> PredicateSet:
|
|
227
|
+
"""Negate one predicate.
|
|
228
|
+
|
|
229
|
+
``not`` is a Python keyword, so the public Python spelling is
|
|
230
|
+
``PredicateSet.not_`` while the serialized AST kind remains ``not``.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
return cls(kind="not", predicates=[predicate])
|
|
234
|
+
|
|
235
|
+
def describe(self) -> str:
|
|
236
|
+
"""Return a plain-English description of this logical combination."""
|
|
237
|
+
|
|
238
|
+
descriptions = [predicate.describe() for predicate in self.predicates]
|
|
239
|
+
if self.kind == "not":
|
|
240
|
+
return f"The following must not be true: {descriptions[0]}"
|
|
241
|
+
joined = " ".join(descriptions)
|
|
242
|
+
if self.kind == "all_of":
|
|
243
|
+
return f"All of the following must be true: {joined}"
|
|
244
|
+
return f"At least one of the following must be true: {joined}"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
Predicate: TypeAlias = Annotated[
|
|
248
|
+
CallShape | Ordering | TaintFlow | FilteredTaintFlow | Outcome | PredicateSet,
|
|
249
|
+
Field(discriminator="kind"),
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
PredicateSet.model_rebuild()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
__all__ = [
|
|
256
|
+
"CallShape",
|
|
257
|
+
"FilteredTaintFlow",
|
|
258
|
+
"Ordering",
|
|
259
|
+
"Outcome",
|
|
260
|
+
"Predicate",
|
|
261
|
+
"PredicateSet",
|
|
262
|
+
"TaintFlow",
|
|
263
|
+
]
|
grudge/schema/scar.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Phase 1 Scar contracts and persistence for admitted regression failures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, TypeAlias
|
|
9
|
+
|
|
10
|
+
from pydantic import ConfigDict, Field
|
|
11
|
+
|
|
12
|
+
from grudge.errors import SchemaError
|
|
13
|
+
from grudge.schema.cassette import Cassette, _SchemaModel
|
|
14
|
+
from grudge.schema.predicate import Predicate
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AdmissionRecord(_SchemaModel):
|
|
18
|
+
"""The admission evidence recorded alongside a Scar."""
|
|
19
|
+
|
|
20
|
+
model_config = ConfigDict(
|
|
21
|
+
extra="allow",
|
|
22
|
+
populate_by_name=True,
|
|
23
|
+
validate_assignment=True,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
admitted: bool
|
|
27
|
+
reasons: list[str] = Field(default_factory=list)
|
|
28
|
+
bad_runs: int = Field(default=0, ge=0)
|
|
29
|
+
good_runs: int = Field(default=0, ge=0)
|
|
30
|
+
checked_at: datetime | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
SeedPredicate: TypeAlias = Predicate | str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Scar(_SchemaModel):
|
|
37
|
+
"""A cassette, behavioural predicate, and provenance metadata."""
|
|
38
|
+
|
|
39
|
+
model_config = ConfigDict(
|
|
40
|
+
extra="forbid",
|
|
41
|
+
populate_by_name=True,
|
|
42
|
+
validate_assignment=True,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
id: str = Field(min_length=1)
|
|
46
|
+
cassette: Cassette
|
|
47
|
+
predicate: Predicate
|
|
48
|
+
seed_predicate: SeedPredicate
|
|
49
|
+
source_trace_hash: str = Field(min_length=1)
|
|
50
|
+
agent_version_failed: str = Field(min_length=1)
|
|
51
|
+
agent_version_fixed: str = Field(min_length=1)
|
|
52
|
+
created_at: datetime
|
|
53
|
+
admission: AdmissionRecord | None = None
|
|
54
|
+
fingerprint: str = Field(min_length=1)
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def _check_path(path: str | Path) -> Path:
|
|
58
|
+
"""Validate and normalize the required Scar filename convention."""
|
|
59
|
+
|
|
60
|
+
target = Path(path)
|
|
61
|
+
if target.name.endswith(".scar.json") is False:
|
|
62
|
+
raise SchemaError("Scar files must use the *.scar.json filename suffix")
|
|
63
|
+
return target
|
|
64
|
+
|
|
65
|
+
def write(self, path: str | Path) -> Path:
|
|
66
|
+
"""Write this Scar as canonical, human-readable JSON."""
|
|
67
|
+
|
|
68
|
+
target = self._check_path(path)
|
|
69
|
+
try:
|
|
70
|
+
target.write_text(
|
|
71
|
+
self.model_dump_json(indent=2) + "\n",
|
|
72
|
+
encoding="utf-8",
|
|
73
|
+
)
|
|
74
|
+
except OSError as exc:
|
|
75
|
+
raise SchemaError(f"could not write Scar file {target}: {exc}") from exc
|
|
76
|
+
return target
|
|
77
|
+
|
|
78
|
+
def save(self, path: str | Path) -> Path:
|
|
79
|
+
"""Alias for :meth:`write` used by callers that prefer persistence verbs."""
|
|
80
|
+
|
|
81
|
+
return self.write(path)
|
|
82
|
+
|
|
83
|
+
def to_file(self, path: str | Path) -> Path:
|
|
84
|
+
"""Alias for :meth:`write`."""
|
|
85
|
+
|
|
86
|
+
return self.write(path)
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def read(cls, path: str | Path) -> Scar:
|
|
90
|
+
"""Read and validate a ``*.scar.json`` file."""
|
|
91
|
+
|
|
92
|
+
target = cls._check_path(path)
|
|
93
|
+
try:
|
|
94
|
+
payload = target.read_text(encoding="utf-8")
|
|
95
|
+
data: Any = json.loads(payload)
|
|
96
|
+
except (OSError, json.JSONDecodeError, UnicodeError) as exc:
|
|
97
|
+
raise SchemaError(f"could not read Scar file {target}: {exc}") from exc
|
|
98
|
+
return cls.model_validate(data)
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def load(cls, path: str | Path) -> Scar:
|
|
102
|
+
"""Alias for :meth:`read`."""
|
|
103
|
+
|
|
104
|
+
return cls.read(path)
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def from_file(cls, path: str | Path) -> Scar:
|
|
108
|
+
"""Alias for :meth:`read` for explicit file-oriented call sites."""
|
|
109
|
+
|
|
110
|
+
return cls.read(path)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
__all__ = ["AdmissionRecord", "Scar", "SeedPredicate"]
|
grudge/schema/trace.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Phase 1 trace contracts for the recorder-to-shrinker pipeline.
|
|
2
|
+
|
|
3
|
+
The module defines the ordered, discriminated step language shared by later
|
|
4
|
+
pipeline stages. JSONL is deliberately canonical: the first record carries
|
|
5
|
+
trace metadata and each following record is one step in the original order.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Annotated, Any, Literal, Self, TypeAlias
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
|
|
16
|
+
|
|
17
|
+
from grudge.errors import SchemaError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _SchemaModel(BaseModel):
|
|
21
|
+
"""Base model that exposes schema failures as Grudge errors."""
|
|
22
|
+
|
|
23
|
+
model_config = ConfigDict(
|
|
24
|
+
extra="allow",
|
|
25
|
+
populate_by_name=True,
|
|
26
|
+
validate_assignment=True,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def __init__(self, **data: Any) -> None:
|
|
30
|
+
try:
|
|
31
|
+
super().__init__(**data)
|
|
32
|
+
except ValidationError as exc:
|
|
33
|
+
raise SchemaError(str(exc)) from exc
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def model_validate(
|
|
37
|
+
cls,
|
|
38
|
+
obj: Any,
|
|
39
|
+
*,
|
|
40
|
+
strict: bool | None = None,
|
|
41
|
+
extra: Literal["allow", "ignore", "forbid"] | None = None,
|
|
42
|
+
from_attributes: bool | None = None,
|
|
43
|
+
context: Any | None = None,
|
|
44
|
+
by_alias: bool | None = None,
|
|
45
|
+
by_name: bool | None = None,
|
|
46
|
+
) -> Self:
|
|
47
|
+
try:
|
|
48
|
+
return super().model_validate(
|
|
49
|
+
obj,
|
|
50
|
+
strict=strict,
|
|
51
|
+
extra=extra,
|
|
52
|
+
from_attributes=from_attributes,
|
|
53
|
+
context=context,
|
|
54
|
+
by_alias=by_alias,
|
|
55
|
+
by_name=by_name,
|
|
56
|
+
)
|
|
57
|
+
except ValidationError as exc:
|
|
58
|
+
raise SchemaError(str(exc)) from exc
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def model_validate_json(
|
|
62
|
+
cls,
|
|
63
|
+
json_data: str | bytes | bytearray,
|
|
64
|
+
*,
|
|
65
|
+
strict: bool | None = None,
|
|
66
|
+
extra: Literal["allow", "ignore", "forbid"] | None = None,
|
|
67
|
+
context: Any | None = None,
|
|
68
|
+
by_alias: bool | None = None,
|
|
69
|
+
by_name: bool | None = None,
|
|
70
|
+
) -> Self:
|
|
71
|
+
try:
|
|
72
|
+
return super().model_validate_json(
|
|
73
|
+
json_data,
|
|
74
|
+
strict=strict,
|
|
75
|
+
extra=extra,
|
|
76
|
+
context=context,
|
|
77
|
+
by_alias=by_alias,
|
|
78
|
+
by_name=by_name,
|
|
79
|
+
)
|
|
80
|
+
except (ValidationError, ValueError) as exc:
|
|
81
|
+
raise SchemaError(str(exc)) from exc
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _StepBase(_SchemaModel):
|
|
85
|
+
"""Fields shared by every trace step."""
|
|
86
|
+
|
|
87
|
+
idx: int = Field(ge=0)
|
|
88
|
+
ts: datetime
|
|
89
|
+
run_id: str = Field(min_length=1)
|
|
90
|
+
parent_idx: int | None = Field(default=None, ge=0)
|
|
91
|
+
meta: dict[str, Any] = Field(default_factory=dict)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ModelCall(_StepBase):
|
|
95
|
+
"""A model request or response observed during an agent run."""
|
|
96
|
+
|
|
97
|
+
kind: Literal["model_call"] = "model_call"
|
|
98
|
+
model: str | None = None
|
|
99
|
+
messages: list[dict[str, Any]] | None = None
|
|
100
|
+
prompt: str | None = None
|
|
101
|
+
response: Any = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ToolCall(_StepBase):
|
|
105
|
+
"""A request for an external tool."""
|
|
106
|
+
|
|
107
|
+
kind: Literal["tool_call"] = "tool_call"
|
|
108
|
+
tool: str = Field(min_length=1)
|
|
109
|
+
args: dict[str, Any]
|
|
110
|
+
call_id: str = Field(min_length=1)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ToolResult(_StepBase):
|
|
114
|
+
"""The result, or error, returned for a tool call."""
|
|
115
|
+
|
|
116
|
+
kind: Literal["tool_result"] = "tool_result"
|
|
117
|
+
call_id: str = Field(min_length=1)
|
|
118
|
+
content: Any
|
|
119
|
+
error: str | None = None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class StateDiff(_StepBase):
|
|
123
|
+
"""A structured change to the agent or world state."""
|
|
124
|
+
|
|
125
|
+
kind: Literal["state_diff"] = "state_diff"
|
|
126
|
+
diff: dict[str, Any] = Field(default_factory=dict)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class FinalAnswer(_StepBase):
|
|
130
|
+
"""The agent's terminal natural-language answer."""
|
|
131
|
+
|
|
132
|
+
kind: Literal["final_answer"] = "final_answer"
|
|
133
|
+
answer: str | None = None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
Step: TypeAlias = Annotated[
|
|
137
|
+
ModelCall | ToolCall | ToolResult | StateDiff | FinalAnswer,
|
|
138
|
+
Field(discriminator="kind"),
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class Trace(_SchemaModel):
|
|
143
|
+
"""An ordered run of an agent, suitable for stable JSONL persistence."""
|
|
144
|
+
|
|
145
|
+
run_id: str = Field(min_length=1)
|
|
146
|
+
agent_version: str = Field(min_length=1)
|
|
147
|
+
task: str
|
|
148
|
+
steps: list[Step] = Field(default_factory=list)
|
|
149
|
+
outcome: Literal["ok", "failed"]
|
|
150
|
+
|
|
151
|
+
@model_validator(mode="after")
|
|
152
|
+
def _validate_step_identity(self) -> Self:
|
|
153
|
+
indexes = [step.idx for step in self.steps]
|
|
154
|
+
if len(indexes) != len(set(indexes)):
|
|
155
|
+
raise ValueError("steps must have unique idx values")
|
|
156
|
+
mismatched = [step.idx for step in self.steps if step.run_id != self.run_id]
|
|
157
|
+
if mismatched:
|
|
158
|
+
raise ValueError(
|
|
159
|
+
f"step run_id must match trace run_id (idx values: {mismatched})"
|
|
160
|
+
)
|
|
161
|
+
return self
|
|
162
|
+
|
|
163
|
+
def tool_calls(self) -> list[ToolCall]:
|
|
164
|
+
"""Return tool calls in their original trace order."""
|
|
165
|
+
|
|
166
|
+
return [step for step in self.steps if isinstance(step, ToolCall)]
|
|
167
|
+
|
|
168
|
+
def results_for(self, call_id: str) -> list[ToolResult]:
|
|
169
|
+
"""Return all results for ``call_id`` in their original trace order."""
|
|
170
|
+
|
|
171
|
+
return [
|
|
172
|
+
step
|
|
173
|
+
for step in self.steps
|
|
174
|
+
if isinstance(step, ToolResult) and step.call_id == call_id
|
|
175
|
+
]
|
|
176
|
+
|
|
177
|
+
def slice(self, idxs: Iterable[int]) -> Trace:
|
|
178
|
+
"""Return a trace containing selected steps, preserving list order."""
|
|
179
|
+
|
|
180
|
+
selected = set(idxs)
|
|
181
|
+
return self.model_copy(
|
|
182
|
+
update={"steps": [step for step in self.steps if step.idx in selected]}
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def to_jsonl(self) -> str:
|
|
186
|
+
"""Serialize this trace to canonical metadata-plus-steps JSONL."""
|
|
187
|
+
|
|
188
|
+
records: list[dict[str, Any]] = [
|
|
189
|
+
{
|
|
190
|
+
"type": "trace",
|
|
191
|
+
"run_id": self.run_id,
|
|
192
|
+
"agent_version": self.agent_version,
|
|
193
|
+
"task": self.task,
|
|
194
|
+
"outcome": self.outcome,
|
|
195
|
+
}
|
|
196
|
+
]
|
|
197
|
+
records.extend(step.model_dump(mode="json") for step in self.steps)
|
|
198
|
+
return (
|
|
199
|
+
"\n".join(
|
|
200
|
+
json.dumps(
|
|
201
|
+
record,
|
|
202
|
+
ensure_ascii=False,
|
|
203
|
+
sort_keys=True,
|
|
204
|
+
separators=(",", ":"),
|
|
205
|
+
)
|
|
206
|
+
for record in records
|
|
207
|
+
)
|
|
208
|
+
+ "\n"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
@classmethod
|
|
212
|
+
def from_jsonl(cls, payload: str) -> Trace:
|
|
213
|
+
"""Parse canonical JSONL and translate malformed input to ``SchemaError``."""
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
if not isinstance(payload, str):
|
|
217
|
+
raise SchemaError("trace JSONL must be text")
|
|
218
|
+
lines = [line for line in payload.split("\n") if line.strip()]
|
|
219
|
+
if not lines:
|
|
220
|
+
raise SchemaError("trace JSONL is empty")
|
|
221
|
+
header = json.loads(lines[0])
|
|
222
|
+
if not isinstance(header, dict) or header.get("type") != "trace":
|
|
223
|
+
raise SchemaError("trace JSONL must begin with a trace header")
|
|
224
|
+
step_payloads = [json.loads(line) for line in lines[1:]]
|
|
225
|
+
if not all(isinstance(step, dict) for step in step_payloads):
|
|
226
|
+
raise SchemaError("each trace JSONL step must be an object")
|
|
227
|
+
header.pop("type", None)
|
|
228
|
+
return cls.model_validate({**header, "steps": step_payloads})
|
|
229
|
+
except SchemaError:
|
|
230
|
+
raise
|
|
231
|
+
except (json.JSONDecodeError, TypeError, ValueError) as exc:
|
|
232
|
+
raise SchemaError(f"invalid trace JSONL: {exc}") from exc
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
__all__ = [
|
|
236
|
+
"FinalAnswer",
|
|
237
|
+
"ModelCall",
|
|
238
|
+
"StateDiff",
|
|
239
|
+
"Step",
|
|
240
|
+
"ToolCall",
|
|
241
|
+
"ToolResult",
|
|
242
|
+
"Trace",
|
|
243
|
+
]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Phase 5 cassette shrinker: oracle evaluation and two-level minimization."""
|
|
2
|
+
|
|
3
|
+
from grudge.shrinker.cassette_builder import ShrinkResult, build_minimal_cassette
|
|
4
|
+
from grudge.shrinker.oracle import Oracle, OracleResult, PredicateOracle
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"Oracle",
|
|
8
|
+
"OracleResult",
|
|
9
|
+
"PredicateOracle",
|
|
10
|
+
"ShrinkResult",
|
|
11
|
+
"build_minimal_cassette",
|
|
12
|
+
]
|