rerunner 0.1.1__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.
- rerunner/__init__.py +27 -0
- rerunner/async_transport.py +108 -0
- rerunner/cassette.py +119 -0
- rerunner/cli.py +203 -0
- rerunner/integrations/__init__.py +13 -0
- rerunner/integrations/anthropic.py +35 -0
- rerunner/integrations/langchain.py +29 -0
- rerunner/integrations/openai.py +33 -0
- rerunner/integrity.py +52 -0
- rerunner/matcher.py +108 -0
- rerunner/models.py +55 -0
- rerunner/py.typed +1 -0
- rerunner/pytest_plugin.py +42 -0
- rerunner/sanitizer.py +59 -0
- rerunner/session.py +65 -0
- rerunner/side_effects.py +125 -0
- rerunner/transport.py +123 -0
- rerunner-0.1.1.dist-info/METADATA +275 -0
- rerunner-0.1.1.dist-info/RECORD +22 -0
- rerunner-0.1.1.dist-info/WHEEL +4 -0
- rerunner-0.1.1.dist-info/entry_points.txt +5 -0
- rerunner-0.1.1.dist-info/licenses/LICENSE +176 -0
rerunner/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Rerunner: Deterministic flight recorder, replay engine, and CI test runner for AI agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rerunner.async_transport import AsyncCassetteTransport
|
|
6
|
+
from rerunner.cassette import Cassette, CassetteError
|
|
7
|
+
from rerunner.sanitizer import sanitize_headers, sanitize_payload, sanitize_string
|
|
8
|
+
from rerunner.session import fork, record, replay
|
|
9
|
+
from rerunner.side_effects import side_effect
|
|
10
|
+
from rerunner.transport import CassetteTransport
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.1"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AsyncCassetteTransport",
|
|
16
|
+
"Cassette",
|
|
17
|
+
"CassetteError",
|
|
18
|
+
"CassetteTransport",
|
|
19
|
+
"fork",
|
|
20
|
+
"record",
|
|
21
|
+
"replay",
|
|
22
|
+
"sanitize_headers",
|
|
23
|
+
"sanitize_payload",
|
|
24
|
+
"sanitize_string",
|
|
25
|
+
"side_effect",
|
|
26
|
+
]
|
|
27
|
+
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Asynchronous httpx.AsyncBaseTransport implementation for recording and replaying HTTP requests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from typing import Literal
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from rerunner.cassette import Cassette, CassetteError
|
|
9
|
+
from rerunner.matcher import (
|
|
10
|
+
compute_request_key,
|
|
11
|
+
normalize_body,
|
|
12
|
+
normalize_headers,
|
|
13
|
+
)
|
|
14
|
+
from rerunner.models import CassetteEntry, RecordedRequest, RecordedResponse
|
|
15
|
+
from rerunner.sanitizer import sanitize_headers, sanitize_string
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AsyncCassetteTransport(httpx.AsyncBaseTransport):
|
|
19
|
+
"""Custom asynchronous httpx transport that records or replays network traffic via a cassette."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
cassette_path: Path | str,
|
|
24
|
+
mode: Literal["record", "replay", "fork"] = "replay",
|
|
25
|
+
fork_step: int | None = None,
|
|
26
|
+
real_transport: httpx.AsyncBaseTransport | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
self.cassette_path = Path(cassette_path)
|
|
29
|
+
self.mode = mode
|
|
30
|
+
self.fork_step = fork_step
|
|
31
|
+
self.real_transport = real_transport or httpx.AsyncHTTPTransport()
|
|
32
|
+
self.current_step = 0
|
|
33
|
+
|
|
34
|
+
if self.mode in ("replay", "fork") and self.cassette_path.exists():
|
|
35
|
+
self.cassette = Cassette.load(self.cassette_path)
|
|
36
|
+
else:
|
|
37
|
+
self.cassette = Cassette(self.cassette_path)
|
|
38
|
+
|
|
39
|
+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
40
|
+
self.current_step += 1
|
|
41
|
+
body_bytes = await request.aread()
|
|
42
|
+
request_key = compute_request_key(request.method, str(request.url), body_bytes)
|
|
43
|
+
|
|
44
|
+
# Check whether to replay
|
|
45
|
+
should_replay = (
|
|
46
|
+
self.mode == "replay"
|
|
47
|
+
or (self.mode == "fork" and self.fork_step is not None and self.current_step <= self.fork_step)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if should_replay:
|
|
51
|
+
match = self.cassette.find_http_match(request_key)
|
|
52
|
+
if match is None or match.response is None:
|
|
53
|
+
raise CassetteError(
|
|
54
|
+
f"[Async Step {self.current_step}] No cassette match found in replay mode for "
|
|
55
|
+
f"{request.method} {request.url} (key: {request_key[:12]}...)"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return httpx.Response(
|
|
59
|
+
status_code=match.response.status_code,
|
|
60
|
+
headers=match.response.headers,
|
|
61
|
+
content=match.response.body.encode("utf-8"),
|
|
62
|
+
request=request,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# LIVE EXECUTION
|
|
66
|
+
real_response = await self.real_transport.handle_async_request(request)
|
|
67
|
+
response_bytes = await real_response.aread()
|
|
68
|
+
|
|
69
|
+
# Sanitize headers and body before writing to disk
|
|
70
|
+
cleaned_headers = sanitize_headers(normalize_headers(dict(request.headers)))
|
|
71
|
+
cleaned_req_body = sanitize_string(normalize_body(body_bytes))
|
|
72
|
+
cleaned_resp_body = sanitize_string(response_bytes.decode("utf-8", errors="replace"))
|
|
73
|
+
|
|
74
|
+
# Record to cassette
|
|
75
|
+
recorded_req = RecordedRequest(
|
|
76
|
+
method=request.method,
|
|
77
|
+
url=str(request.url),
|
|
78
|
+
headers=cleaned_headers,
|
|
79
|
+
body=cleaned_req_body,
|
|
80
|
+
request_key=request_key,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
recorded_resp = RecordedResponse(
|
|
84
|
+
status_code=real_response.status_code,
|
|
85
|
+
headers=sanitize_headers(dict(real_response.headers)),
|
|
86
|
+
body=cleaned_resp_body,
|
|
87
|
+
http_version=real_response.extensions.get("http_version", b"HTTP/1.1").decode("ascii", errors="ignore"),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
entry = CassetteEntry(
|
|
91
|
+
step=self.current_step,
|
|
92
|
+
entry_type="http",
|
|
93
|
+
request=recorded_req,
|
|
94
|
+
response=recorded_resp,
|
|
95
|
+
)
|
|
96
|
+
self.cassette.append(entry)
|
|
97
|
+
self.cassette.save()
|
|
98
|
+
|
|
99
|
+
return httpx.Response(
|
|
100
|
+
status_code=real_response.status_code,
|
|
101
|
+
headers=real_response.headers,
|
|
102
|
+
content=response_bytes,
|
|
103
|
+
request=request,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
async def aclose(self) -> None:
|
|
107
|
+
if hasattr(self.real_transport, "aclose"):
|
|
108
|
+
await self.real_transport.aclose()
|
rerunner/cassette.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Cassette storage engine for streaming plain and gzip-compressed JSONL reading and writing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
from rerunner.integrity import compute_entry_hash, verify_cassette_integrity
|
|
9
|
+
from rerunner.models import CassetteEntry, CassetteMetadata
|
|
10
|
+
import gzip
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CassetteError(Exception):
|
|
17
|
+
"""Raised when cassette file operations fail or integrity is violated."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Cassette:
|
|
21
|
+
"""Represents a recorded agent session cassette stored as append-only JSONL (.jsonl) or gzip-compressed JSONL (.jsonl.gz)."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, path: Path | str, metadata: CassetteMetadata | None = None) -> None:
|
|
24
|
+
self.path = Path(path)
|
|
25
|
+
self.metadata = metadata or CassetteMetadata()
|
|
26
|
+
self.entries: list[CassetteEntry] = []
|
|
27
|
+
self._last_hash = "GENESIS"
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def load(cls, path: Path | str, verify_integrity: bool = True) -> Cassette:
|
|
31
|
+
"""Load an existing plain or gzip-compressed cassette from disk and optionally verify its cryptographic chain."""
|
|
32
|
+
file_path = Path(path)
|
|
33
|
+
if not file_path.exists():
|
|
34
|
+
raise FileNotFoundError(f"Cassette file not found: {file_path}")
|
|
35
|
+
|
|
36
|
+
if file_path.name.endswith(".gz"):
|
|
37
|
+
with gzip.open(file_path, "rt", encoding="utf-8") as file:
|
|
38
|
+
lines = file.read().splitlines()
|
|
39
|
+
else:
|
|
40
|
+
lines = file_path.read_text(encoding="utf-8").splitlines()
|
|
41
|
+
|
|
42
|
+
if not lines:
|
|
43
|
+
raise CassetteError(f"Cassette file is empty: {file_path}")
|
|
44
|
+
|
|
45
|
+
# Parse header
|
|
46
|
+
try:
|
|
47
|
+
header_dict = json.loads(lines[0])
|
|
48
|
+
metadata = CassetteMetadata(**header_dict)
|
|
49
|
+
except Exception as exc:
|
|
50
|
+
raise CassetteError(f"Invalid cassette metadata header in {file_path}: {exc}") from exc
|
|
51
|
+
|
|
52
|
+
cassette = cls(file_path, metadata=metadata)
|
|
53
|
+
|
|
54
|
+
for line_num, line in enumerate(lines[1:], start=2):
|
|
55
|
+
line_str = line.strip()
|
|
56
|
+
if not line_str:
|
|
57
|
+
continue
|
|
58
|
+
try:
|
|
59
|
+
entry_dict = json.loads(line_str)
|
|
60
|
+
entry = CassetteEntry(**entry_dict)
|
|
61
|
+
cassette.entries.append(entry)
|
|
62
|
+
except Exception as exc:
|
|
63
|
+
raise CassetteError(
|
|
64
|
+
f"Malformed entry on line {line_num} in {file_path}: {exc}"
|
|
65
|
+
) from exc
|
|
66
|
+
|
|
67
|
+
if cassette.entries:
|
|
68
|
+
cassette._last_hash = cassette.entries[-1].entry_hash
|
|
69
|
+
|
|
70
|
+
if verify_integrity:
|
|
71
|
+
is_valid, error = verify_cassette_integrity(cassette.entries)
|
|
72
|
+
if not is_valid:
|
|
73
|
+
raise CassetteError(f"Integrity check failed for {file_path}: {error}")
|
|
74
|
+
|
|
75
|
+
return cassette
|
|
76
|
+
|
|
77
|
+
def append(self, entry: CassetteEntry) -> None:
|
|
78
|
+
"""Append a new entry to the in-memory cassette with tamper-evident chaining."""
|
|
79
|
+
entry.prev_hash = self._last_hash
|
|
80
|
+
entry.entry_hash = compute_entry_hash(entry, self._last_hash)
|
|
81
|
+
self.entries.append(entry)
|
|
82
|
+
self._last_hash = entry.entry_hash
|
|
83
|
+
|
|
84
|
+
def save(self) -> None:
|
|
85
|
+
"""Write the complete cassette to disk atomically, using gzip compression for .gz paths."""
|
|
86
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
temp_path = self.path.with_suffix(f"{self.path.suffix}.tmp")
|
|
88
|
+
|
|
89
|
+
if self.path.name.endswith(".gz"):
|
|
90
|
+
with gzip.open(temp_path, "wt", encoding="utf-8") as file:
|
|
91
|
+
file.write(self.metadata.model_dump_json() + "\n")
|
|
92
|
+
for entry in self.entries:
|
|
93
|
+
file.write(entry.model_dump_json() + "\n")
|
|
94
|
+
else:
|
|
95
|
+
with temp_path.open("w", encoding="utf-8") as file:
|
|
96
|
+
# Write metadata header
|
|
97
|
+
file.write(self.metadata.model_dump_json() + "\n")
|
|
98
|
+
for entry in self.entries:
|
|
99
|
+
file.write(entry.model_dump_json() + "\n")
|
|
100
|
+
|
|
101
|
+
temp_path.replace(self.path)
|
|
102
|
+
|
|
103
|
+
def find_http_match(self, request_key: str, step: int | None = None) -> CassetteEntry | None:
|
|
104
|
+
"""Find a recorded HTTP response matching the request key, optionally at a specific step."""
|
|
105
|
+
for entry in self.entries:
|
|
106
|
+
if (
|
|
107
|
+
entry.entry_type == "http"
|
|
108
|
+
and entry.request
|
|
109
|
+
and entry.request.request_key == request_key
|
|
110
|
+
and (step is None or entry.step == step)
|
|
111
|
+
):
|
|
112
|
+
return entry
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
def __len__(self) -> int:
|
|
116
|
+
return len(self.entries)
|
|
117
|
+
|
|
118
|
+
def __iter__(self) -> Iterator[CassetteEntry]:
|
|
119
|
+
return iter(self.entries)
|
rerunner/cli.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Command-line interface for Rerunner built with Typer and Rich."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
from rerunner.cassette import Cassette
|
|
12
|
+
from rerunner.integrity import verify_cassette_integrity
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(
|
|
15
|
+
name="rerunner",
|
|
16
|
+
help="Deterministic flight recorder, replay engine, and CI test runner for AI agents.",
|
|
17
|
+
add_completion=False,
|
|
18
|
+
)
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.command()
|
|
23
|
+
def inspect(
|
|
24
|
+
cassette_path: Path = typer.Argument(..., help="Path to a .jsonl or .jsonl.gz cassette file"),
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Inspect and summarize the contents of a recorded cassette."""
|
|
27
|
+
try:
|
|
28
|
+
cassette = Cassette.load(cassette_path, verify_integrity=False)
|
|
29
|
+
except Exception as exc:
|
|
30
|
+
console.print(f"[bold red]Failed to load cassette:[/] {exc}")
|
|
31
|
+
raise typer.Exit(code=1) from exc
|
|
32
|
+
|
|
33
|
+
console.print(
|
|
34
|
+
Panel(
|
|
35
|
+
f"[bold cyan]File:[/] {cassette_path}\n"
|
|
36
|
+
f"[bold cyan]Format Version:[/] {cassette.metadata.format_version}\n"
|
|
37
|
+
f"[bold cyan]Hash Algorithm:[/] {cassette.metadata.hash_algorithm}\n"
|
|
38
|
+
f"[bold cyan]Total Steps:[/] {len(cassette.entries)}",
|
|
39
|
+
title="[bold green]Rerunner Cassette Summary[/]",
|
|
40
|
+
border_style="green",
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if not cassette.entries:
|
|
45
|
+
console.print("[yellow]Cassette contains no recorded steps.[/]")
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
table = Table(title="Recorded Execution Steps", show_header=True, header_style="bold magenta")
|
|
49
|
+
table.add_column("Step", style="dim", width=6)
|
|
50
|
+
table.add_column("Type", width=12)
|
|
51
|
+
table.add_column("Details", justify="left")
|
|
52
|
+
table.add_column("Hash (SHA-256)", style="dim", width=12)
|
|
53
|
+
|
|
54
|
+
for entry in cassette.entries:
|
|
55
|
+
if entry.entry_type == "http" and entry.request and entry.response:
|
|
56
|
+
details = f"[bold]{entry.request.method}[/] {entry.request.url} -> [green]{entry.response.status_code}[/]"
|
|
57
|
+
elif entry.entry_type == "side_effect":
|
|
58
|
+
details = f"[bold cyan]Tool:[/] {entry.side_effect_name}(...)"
|
|
59
|
+
else:
|
|
60
|
+
details = "Unknown payload"
|
|
61
|
+
|
|
62
|
+
hash_short = f"{entry.entry_hash[:10]}..." if entry.entry_hash else "None"
|
|
63
|
+
table.add_row(str(entry.step), entry.entry_type, details, hash_short)
|
|
64
|
+
|
|
65
|
+
console.print(table)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.command()
|
|
69
|
+
def verify(
|
|
70
|
+
cassette_path: Path = typer.Argument(..., help="Path to the .jsonl cassette file"),
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Verify the cryptographic SHA-256 tamper-evident integrity of a cassette."""
|
|
73
|
+
try:
|
|
74
|
+
cassette = Cassette.load(cassette_path, verify_integrity=False)
|
|
75
|
+
is_valid, error = verify_cassette_integrity(cassette.entries)
|
|
76
|
+
if not is_valid:
|
|
77
|
+
console.print(f"[bold red]❌ INTEGRITY FAILURE:[/] {error}")
|
|
78
|
+
sys.exit(1)
|
|
79
|
+
console.print(
|
|
80
|
+
f"[bold green]✔ Cassette integrity verified successfully! ({len(cassette.entries)} chained steps)[/]"
|
|
81
|
+
)
|
|
82
|
+
except Exception as exc:
|
|
83
|
+
console.print(f"[bold red]Error verifying cassette:[/] {exc}")
|
|
84
|
+
sys.exit(1)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@app.command()
|
|
88
|
+
def diff(
|
|
89
|
+
cassette_a: Path = typer.Argument(..., help="First cassette file"),
|
|
90
|
+
cassette_b: Path = typer.Argument(..., help="Second cassette file"),
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Compare two cassettes and highlight divergences in prompt execution and outputs."""
|
|
93
|
+
try:
|
|
94
|
+
c1 = Cassette.load(cassette_a, verify_integrity=False)
|
|
95
|
+
c2 = Cassette.load(cassette_b, verify_integrity=False)
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
console.print(f"[bold red]Error loading cassettes for comparison:[/] {exc}")
|
|
98
|
+
sys.exit(1)
|
|
99
|
+
|
|
100
|
+
table = Table(title="Cassette Divergence Diff", show_header=True, header_style="bold cyan")
|
|
101
|
+
table.add_column("Step", width=6)
|
|
102
|
+
table.add_column(f"{cassette_a.name}", style="yellow")
|
|
103
|
+
table.add_column(f"{cassette_b.name}", style="green")
|
|
104
|
+
|
|
105
|
+
max_len = max(len(c1.entries), len(c2.entries))
|
|
106
|
+
for i in range(max_len):
|
|
107
|
+
e1 = c1.entries[i] if i < len(c1.entries) else None
|
|
108
|
+
e2 = c2.entries[i] if i < len(c2.entries) else None
|
|
109
|
+
|
|
110
|
+
desc1 = f"{e1.entry_type}: {e1.entry_hash[:8]}" if e1 else "[red]<Missing>[/]"
|
|
111
|
+
desc2 = f"{e2.entry_type}: {e2.entry_hash[:8]}" if e2 else "[red]<Missing>[/]"
|
|
112
|
+
|
|
113
|
+
table.add_row(str(i + 1), desc1, desc2)
|
|
114
|
+
|
|
115
|
+
console.print(table)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@app.command()
|
|
119
|
+
def stats(
|
|
120
|
+
cassette_path: Path = typer.Argument(..., help="Path to the .jsonl cassette file"),
|
|
121
|
+
) -> None:
|
|
122
|
+
"""Calculate token savings, latency savings, and cost reductions achieved by replaying."""
|
|
123
|
+
try:
|
|
124
|
+
cassette = Cassette.load(cassette_path, verify_integrity=False)
|
|
125
|
+
except Exception as exc:
|
|
126
|
+
console.print(f"[bold red]Error loading cassette:[/] {exc}")
|
|
127
|
+
sys.exit(1)
|
|
128
|
+
|
|
129
|
+
total_chars = 0
|
|
130
|
+
http_steps = 0
|
|
131
|
+
side_effect_steps = 0
|
|
132
|
+
|
|
133
|
+
for entry in cassette.entries:
|
|
134
|
+
if entry.entry_type == "http" and entry.request and entry.response:
|
|
135
|
+
http_steps += 1
|
|
136
|
+
total_chars += len(entry.request.body) + len(entry.response.body)
|
|
137
|
+
elif entry.entry_type == "side_effect":
|
|
138
|
+
side_effect_steps += 1
|
|
139
|
+
|
|
140
|
+
est_tokens = max(1, total_chars // 4)
|
|
141
|
+
# Estimate based on blend of Claude 3.5 Sonnet / GPT-4o pricing (~$10/M tokens)
|
|
142
|
+
est_dollars_saved = (est_tokens / 1_000_000) * 10.0
|
|
143
|
+
# Average live LLM network call takes ~1.8 seconds vs 0.001s replay
|
|
144
|
+
time_saved_seconds = http_steps * 1.8
|
|
145
|
+
|
|
146
|
+
stats_table = Table(
|
|
147
|
+
title="CI Replay Efficiency & Savings", show_header=True, header_style="bold green"
|
|
148
|
+
)
|
|
149
|
+
stats_table.add_column("Metric", style="cyan")
|
|
150
|
+
stats_table.add_column("Value", style="bold yellow")
|
|
151
|
+
|
|
152
|
+
stats_table.add_row("Total Recorded Steps", str(len(cassette.entries)))
|
|
153
|
+
stats_table.add_row("HTTP LLM Calls Avoided", str(http_steps))
|
|
154
|
+
stats_table.add_row("Side-Effects Safely Stubbed", str(side_effect_steps))
|
|
155
|
+
stats_table.add_row("Estimated Tokens Saved", f"{est_tokens:,}")
|
|
156
|
+
stats_table.add_row("Estimated API Cost Saved (per test run)", f"${est_dollars_saved:.4f}")
|
|
157
|
+
stats_table.add_row("CI Pipeline Time Saved (per test run)", f"{time_saved_seconds:.1f}s")
|
|
158
|
+
|
|
159
|
+
console.print(stats_table)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@app.command()
|
|
163
|
+
def play(
|
|
164
|
+
cassette_path: Path = typer.Argument(..., help="Path to the .jsonl cassette file"),
|
|
165
|
+
) -> None:
|
|
166
|
+
"""Visually step through recorded agent steps in the terminal."""
|
|
167
|
+
try:
|
|
168
|
+
cassette = Cassette.load(cassette_path, verify_integrity=False)
|
|
169
|
+
except Exception as exc:
|
|
170
|
+
console.print(f"[bold red]Error loading cassette:[/] {exc}")
|
|
171
|
+
sys.exit(1)
|
|
172
|
+
|
|
173
|
+
console.print(
|
|
174
|
+
Panel(
|
|
175
|
+
f"[bold green]Replaying {len(cassette.entries)} recorded agent steps...[/]",
|
|
176
|
+
title="📼 Flight Playback",
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
for entry in cassette.entries:
|
|
181
|
+
if entry.entry_type == "http" and entry.request and entry.response:
|
|
182
|
+
console.print(
|
|
183
|
+
Panel(
|
|
184
|
+
f"[bold yellow]Method:[/] {entry.request.method} {entry.request.url}\n"
|
|
185
|
+
f"[bold yellow]Response Code:[/] [green]{entry.response.status_code}[/]\n"
|
|
186
|
+
f"[dim]Body Preview:[/] {entry.response.body[:180]}...",
|
|
187
|
+
title=f"Step {entry.step}: LLM Network Interaction",
|
|
188
|
+
border_style="blue",
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
elif entry.entry_type == "side_effect":
|
|
192
|
+
console.print(
|
|
193
|
+
Panel(
|
|
194
|
+
f"[bold cyan]Function:[/] {entry.side_effect_name}\n"
|
|
195
|
+
f"[bold cyan]Result:[/] {entry.side_effect_result}",
|
|
196
|
+
title=f"Step {entry.step}: Guarded Side-Effect",
|
|
197
|
+
border_style="magenta",
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
if __name__ == "__main__":
|
|
203
|
+
app()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Official framework integrations and zero-friction client adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rerunner.integrations.anthropic import wrap_anthropic
|
|
6
|
+
from rerunner.integrations.langchain import RerunnerCallbackHandler
|
|
7
|
+
from rerunner.integrations.openai import wrap_openai
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"RerunnerCallbackHandler",
|
|
11
|
+
"wrap_anthropic",
|
|
12
|
+
"wrap_openai",
|
|
13
|
+
]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Zero-friction wrapper for the official Anthropic Claude SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
6
|
+
import httpx
|
|
7
|
+
from rerunner.transport import CassetteTransport
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def wrap_anthropic(
|
|
14
|
+
client: Any,
|
|
15
|
+
cassette_path: Path | str,
|
|
16
|
+
mode: Literal["record", "replay", "fork"] = "replay",
|
|
17
|
+
fork_step: int | None = None,
|
|
18
|
+
) -> Any:
|
|
19
|
+
"""Attach a Rerunner CassetteTransport to an existing Anthropic client instance.
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
import anthropic
|
|
23
|
+
from rerunner.integrations import wrap_anthropic
|
|
24
|
+
|
|
25
|
+
client = wrap_anthropic(anthropic.Anthropic(), "my_run.jsonl", mode="replay")
|
|
26
|
+
"""
|
|
27
|
+
transport = CassetteTransport(cassette_path=cassette_path, mode=mode, fork_step=fork_step)
|
|
28
|
+
# The official Anthropic SDK uses an internal httpx._client.Client or _client
|
|
29
|
+
if hasattr(client, "_client") and hasattr(client._client, "_transport"):
|
|
30
|
+
client._client._transport = transport
|
|
31
|
+
else:
|
|
32
|
+
# Fallback to replacing http_client
|
|
33
|
+
client._client = httpx.Client(transport=transport)
|
|
34
|
+
|
|
35
|
+
return client
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""LangChain and LangGraph callback integration for step recording and observability."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from rerunner.side_effects import set_active_cassette
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RerunnerCallbackHandler:
|
|
10
|
+
"""Callback handler compatible with LangChain, LangGraph, and CrewAI pipelines."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, cassette_path: str, mode: str = "replay", fork_step: int | None = None) -> None:
|
|
13
|
+
self.cassette_path = cassette_path
|
|
14
|
+
self.mode = mode
|
|
15
|
+
self.fork_step = fork_step
|
|
16
|
+
self.step_count = 0
|
|
17
|
+
set_active_cassette(cassette_path, mode=mode, fork_step=fork_step)
|
|
18
|
+
|
|
19
|
+
def on_llm_start(self, serialized: dict[str, Any], prompts: list[str], **kwargs: Any) -> None:
|
|
20
|
+
"""Invoked when an LLM call begins in a LangChain chain."""
|
|
21
|
+
self.step_count += 1
|
|
22
|
+
|
|
23
|
+
def on_tool_start(self, serialized: dict[str, Any], input_str: str, **kwargs: Any) -> None:
|
|
24
|
+
"""Invoked when a tool executes in an agent workflow."""
|
|
25
|
+
self.step_count += 1
|
|
26
|
+
|
|
27
|
+
def on_chain_end(self, outputs: dict[str, Any], **kwargs: Any) -> None:
|
|
28
|
+
"""Invoked when a chain completes execution."""
|
|
29
|
+
pass
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Zero-friction wrapper for the official OpenAI SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
6
|
+
import httpx
|
|
7
|
+
from rerunner.transport import CassetteTransport
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def wrap_openai(
|
|
14
|
+
client: Any,
|
|
15
|
+
cassette_path: Path | str,
|
|
16
|
+
mode: Literal["record", "replay", "fork"] = "replay",
|
|
17
|
+
fork_step: int | None = None,
|
|
18
|
+
) -> Any:
|
|
19
|
+
"""Attach a Rerunner CassetteTransport to an existing OpenAI client instance.
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
import openai
|
|
23
|
+
from rerunner.integrations import wrap_openai
|
|
24
|
+
|
|
25
|
+
client = wrap_openai(openai.OpenAI(), "my_run.jsonl", mode="replay")
|
|
26
|
+
"""
|
|
27
|
+
transport = CassetteTransport(cassette_path=cassette_path, mode=mode, fork_step=fork_step)
|
|
28
|
+
if hasattr(client, "_client") and hasattr(client._client, "_transport"):
|
|
29
|
+
client._client._transport = transport
|
|
30
|
+
else:
|
|
31
|
+
client._client = httpx.Client(transport=transport)
|
|
32
|
+
|
|
33
|
+
return client
|
rerunner/integrity.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Cryptographic tamper-evident hash chaining for cassette verification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from rerunner.models import CassetteEntry
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def canonical_entry_payload(entry: CassetteEntry) -> str:
|
|
14
|
+
"""Create a deterministic JSON string representing the entry without its hash."""
|
|
15
|
+
data = entry.model_dump(exclude={"entry_hash"})
|
|
16
|
+
return json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def compute_entry_hash(entry: CassetteEntry, prev_hash: str) -> str:
|
|
20
|
+
"""Compute SHA-256 hash chaining prev_hash with canonical entry payload."""
|
|
21
|
+
payload = f"{prev_hash}:{canonical_entry_payload(entry)}"
|
|
22
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def verify_cassette_integrity(entries: list[CassetteEntry]) -> tuple[bool, str | None]:
|
|
26
|
+
"""Verify that every entry in the cassette adheres to the cryptographic hash chain.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
(True, None) if the chain is fully verified and untampered.
|
|
30
|
+
(False, error_message) if any link in the chain is broken or modified.
|
|
31
|
+
"""
|
|
32
|
+
if not entries:
|
|
33
|
+
return True, None
|
|
34
|
+
|
|
35
|
+
expected_prev = "GENESIS"
|
|
36
|
+
for entry in entries:
|
|
37
|
+
if entry.prev_hash != expected_prev:
|
|
38
|
+
return (
|
|
39
|
+
False,
|
|
40
|
+
f"Broken chain at step {entry.step}: expected prev_hash '{expected_prev}', got '{entry.prev_hash}'",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
recomputed_hash = compute_entry_hash(entry, expected_prev)
|
|
44
|
+
if recomputed_hash != entry.entry_hash:
|
|
45
|
+
return (
|
|
46
|
+
False,
|
|
47
|
+
f"Tampered entry detected at step {entry.step}: recorded hash '{entry.entry_hash}' does not match recomputed '{recomputed_hash}'",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
expected_prev = entry.entry_hash
|
|
51
|
+
|
|
52
|
+
return True, None
|