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.
Files changed (89) hide show
  1. examples/__init__.py +1 -0
  2. examples/leaky_agent/__init__.py +1 -0
  3. examples/leaky_agent/agent.py +250 -0
  4. examples/leaky_agent/agent_fixed.py +183 -0
  5. examples/leaky_agent/tools.py +153 -0
  6. grudge/__init__.py +5 -0
  7. grudge/__main__.py +6 -0
  8. grudge/_stubs.py +8 -0
  9. grudge/admission/__init__.py +6 -0
  10. grudge/admission/court.py +239 -0
  11. grudge/admission/verdict.py +26 -0
  12. grudge/cli/__init__.py +1 -0
  13. grudge/cli/_common.py +126 -0
  14. grudge/cli/admit.py +93 -0
  15. grudge/cli/capture.py +39 -0
  16. grudge/cli/compile.py +37 -0
  17. grudge/cli/demo.py +136 -0
  18. grudge/cli/main.py +54 -0
  19. grudge/cli/run.py +60 -0
  20. grudge/cli/scars.py +91 -0
  21. grudge/cli/shrink.py +108 -0
  22. grudge/config.py +35 -0
  23. grudge/demo/__init__.py +1 -0
  24. grudge/demo/events.py +228 -0
  25. grudge/demo/orchestrator.py +326 -0
  26. grudge/demo/server.py +222 -0
  27. grudge/errors.py +25 -0
  28. grudge/fuzzer/__init__.py +1 -0
  29. grudge/fuzzer/fingerprint_guard.py +10 -0
  30. grudge/fuzzer/mutators.py +10 -0
  31. grudge/gate/__init__.py +5 -0
  32. grudge/gate/report.py +125 -0
  33. grudge/gate/runner.py +323 -0
  34. grudge/gate/selector.py +10 -0
  35. grudge/gate/wilson.py +36 -0
  36. grudge/guard/__init__.py +5 -0
  37. grudge/guard/compile.py +251 -0
  38. grudge/guard/import_session.py +106 -0
  39. grudge/integrations/__init__.py +1 -0
  40. grudge/integrations/github_comment.py +10 -0
  41. grudge/integrations/pytest_plugin.py +10 -0
  42. grudge/llm/__init__.py +6 -0
  43. grudge/llm/cache.py +92 -0
  44. grudge/llm/client.py +82 -0
  45. grudge/locator/__init__.py +1 -0
  46. grudge/locator/fork.py +10 -0
  47. grudge/locator/point_of_no_return.py +10 -0
  48. grudge/predicates/__init__.py +25 -0
  49. grudge/predicates/call_shape.py +116 -0
  50. grudge/predicates/compiler.py +116 -0
  51. grudge/predicates/dsl.py +295 -0
  52. grudge/predicates/fallback_rules.py +10 -0
  53. grudge/predicates/ordering.py +33 -0
  54. grudge/predicates/outcome.py +14 -0
  55. grudge/predicates/prompts/synthesize.md +15 -0
  56. grudge/predicates/synthesizer.py +127 -0
  57. grudge/predicates/taint_flow.py +84 -0
  58. grudge/recorder/__init__.py +5 -0
  59. grudge/recorder/adapters/__init__.py +1 -0
  60. grudge/recorder/adapters/langgraph.py +8 -0
  61. grudge/recorder/adapters/mcp_proxy.py +8 -0
  62. grudge/recorder/adapters/raw_sdk.py +159 -0
  63. grudge/recorder/recorder.py +374 -0
  64. grudge/recorder/redact.py +105 -0
  65. grudge/registry/__init__.py +1 -0
  66. grudge/registry/db.py +10 -0
  67. grudge/registry/fingerprint.py +10 -0
  68. grudge/registry/flakiness.py +10 -0
  69. grudge/registry/retirement.py +10 -0
  70. grudge/replay/__init__.py +19 -0
  71. grudge/replay/cassette_player.py +159 -0
  72. grudge/replay/sandbox.py +340 -0
  73. grudge/replay/scripted_llm.py +382 -0
  74. grudge/schema/__init__.py +45 -0
  75. grudge/schema/cassette.py +206 -0
  76. grudge/schema/predicate.py +263 -0
  77. grudge/schema/scar.py +113 -0
  78. grudge/schema/trace.py +243 -0
  79. grudge/shrinker/__init__.py +12 -0
  80. grudge/shrinker/cassette_builder.py +328 -0
  81. grudge/shrinker/ddmin.py +243 -0
  82. grudge/shrinker/oracle.py +230 -0
  83. grudge/shrinker/reducers.py +331 -0
  84. grudge_cli-0.1.0.dist-info/LICENSE +21 -0
  85. grudge_cli-0.1.0.dist-info/METADATA +313 -0
  86. grudge_cli-0.1.0.dist-info/RECORD +89 -0
  87. grudge_cli-0.1.0.dist-info/WHEEL +5 -0
  88. grudge_cli-0.1.0.dist-info/entry_points.txt +2 -0
  89. 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
+ ]