Replay¶
Deterministic replay of agent runs with cassettes (docs/design.md §4, ADR 0004).
Record the LLM and tool calls of a run once, then play them back without the network, so a change to the agent's code can be tested in isolation:
from agentlens.replay import Cassette
cassette = Cassette() # mode="record"
llm = agentlens.wrap_llm(cassette.wrap_llm(real_client))
lookup = agentlens.wrap_tool(cassette.wrap_tool(lookup_order))
...
cassette.save("cassettes/refund.jsonl")
cassette = Cassette.load("cassettes/refund.jsonl") # mode="strict"
llm = agentlens.wrap_llm(cassette.wrap_llm()) # no real client needed
Modes: strict (index and request key must match, else
CassetteMissError), lenient (index only), fallthrough (a miss
calls the real client, and with record_new=True records it).
Where a replay departs from the recording is reported as a
ReplayDivergence (call index + unified request diff): on the strict
error (exc.divergence), in cassette.divergences /
cassette.first_divergence in every mode, on the trace (replay_diverged
span attribute, replay_divergence trace metadata), and by
find_divergence for two recorded traces.
CassetteMode
module-attribute
¶
How a cassette answers calls; see agentlens.replay.cassette.
EntryKind
module-attribute
¶
The two kinds of recorded calls. Each kind has its own index sequence.
CASSETTE_FORMAT
module-attribute
¶
Value of format in the header line of a cassette file.
CASSETTE_VERSION
module-attribute
¶
Current cassette file format version.
DIVERGED_ATTR
module-attribute
¶
Span attribute set to True on a replayed call that diverged.
DIVERGENCE_ATTR
module-attribute
¶
Span attribute with the divergence summary; also the
trace metadata key holding the first divergence's
to_dict (without the full requests).
DIFF_MAX_LINE_CHARS
module-attribute
¶
Default maximum length of one diff line (longer lines are cut with ...).
DIFF_MAX_LINES
module-attribute
¶
Default maximum number of lines kept in a divergence diff.
DivergenceReason
module-attribute
¶
Why a call diverged:
request_changed: same call (and tool name), different request;tool_changed: the replay called another tool than the recording at this index;extra_call: the replay made a call the recording does not have;missing_call: the recording has a call the replay did not make (only detectable once the replay is over, e.g. byfind_divergence).
Cassette ¶
Cassette(entries: Iterable[CassetteEntry] = (), *, mode: CassetteMode = 'record', record_new: bool = False, metadata: dict[str, JsonValue] | None = None)
Recorded LLM and tool calls, with record and playback modes.
Wrap clients with wrap_llm and tools with wrap_tool; all
wrappers of one cassette share its call counters. Wrap inside the
tracing wrappers (agentlens.wrap_llm(cassette.wrap_llm(client))) so
played-back calls are traced exactly like live ones. Errors raised by the
real client or tool are not recorded (the call does not consume an index).
Calls made from inside a real tool execution are passed straight through.
Calls are indexed in the order they reach the cassette; concurrent calls through one cassette are safe but their order (and so their index) is only as deterministic as the agent's scheduling.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
entries
|
Iterable[CassetteEntry]
|
Initial entries (e.g. from a file); |
()
|
mode
|
CassetteMode
|
|
'record'
|
record_new
|
bool
|
In |
False
|
metadata
|
dict[str, JsonValue] | None
|
Free JSON metadata saved in the file header. |
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
for an unknown mode. |
CassetteFormatError
|
if two entries share a |
mode
property
writable
¶
mode: CassetteMode
The current mode; assign to switch (e.g. from "record" to "strict").
record_new
instance-attribute
¶
In fallthrough mode, store calls answered by the real client/tool.
metadata
instance-attribute
¶
Free JSON metadata (saved in the header); model/provider are
used as defaults by wrap_llm during playback.
entries
property
¶
entries: list[CassetteEntry]
All entries, in the order they were recorded (or loaded).
unplayed
property
¶
unplayed: list[CassetteEntry]
Entries not reached yet by playback (useful to assert a replay was complete).
divergences
property
¶
divergences: list[ReplayDivergence]
Calls of this playback whose request differed from the recording, in call order.
Filled in every playback mode: strict (each one also raised a
CassetteMissError), lenient (the recorded response was
played anyway) and fallthrough (the real client/tool answered).
Cleared by rewind.
first_divergence
property
¶
first_divergence: ReplayDivergence | None
The first entry of divergences, or None if the replay matched so far.
entries_of ¶
entries_of(kind: EntryKind) -> list[CassetteEntry]
The entries of one kind, sorted by index.
rewind ¶
Reset the call counters and divergences, to record or play again from the start.
wrap_llm ¶
wrap_llm(client: LLMClient | None = None, *, model: str | None = None, provider: str | None = None) -> CassetteLLM
Return an LLMClient answering through this cassette.
client is the real client: required to record and for
fallthrough misses, optional for strict/lenient playback.
The returned client's model/provider attributes (read by
agentlens.wrap_llm for span names and attributes) are the
arguments, else the client's, else the cassette metadata values;
the client's values are stored in metadata when it has none yet.
wrap_tool ¶
wrap_tool(fn: Callable[P, R] | None = None, /, *, name: str | None = None) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]
Record/play back calls of a tool (sync or async), like wrap_llm for LLMs.
Usable as a wrapper or decorator (@cassette.wrap_tool). Calls are
keyed by tool_key of the tool name (name, else
fn.__name__) and the JSON of the arguments by parameter name.
Playback returns the recorded return value as JSON (dicts, lists,
strings, numbers), so tools returning other objects get their JSON form.
The wrapper's __name__ is the tool name, so wrapping it with
agentlens.wrap_tool gives the span that name too.
A function already wrapped by agentlens.wrap_tool is unwrapped,
put behind the cassette and traced again, so its tool_call span is
still recorded when the tool is played back.
save ¶
Write the cassette as JSONL; return the number of entries written.
Line 1 is the CassetteHeader, then one CassetteEntry
per line in recording order. The file is written to a temporary
sibling and renamed atomically; parent directories are created.
load
classmethod
¶
load(path: str | PathLike[str], *, mode: CassetteMode = 'strict', record_new: bool = False) -> Cassette
Read a cassette written by save (default mode: strict).
The header line is optional (a hand-written file may be just entries) but, if present, must be the first non-blank line. Blank lines are skipped.
Raises:
| Type | Description |
|---|---|
CassetteFormatError
|
on an invalid line, an unsupported header or a
duplicate |
from_trace
classmethod
¶
from_trace(trace: Trace, *, mode: CassetteMode = 'strict', record_new: bool = False) -> Cassette
Build a cassette from the llm_call and tool_call spans of a recorded trace.
Works for traces recorded with agentlens.wrap_llm /
agentlens.wrap_tool (with or without a cassette): spans are
taken in start order, failed or unfinished spans and spans nested
inside a tool_call are skipped (mirroring what recording stores).
LLM responses get the span's usage back; the provider raw
payload is only available if the trace was recorded with
include_raw=True. metadata records the trace id and name and,
when known, the client's default model and provider.
Raises:
| Type | Description |
|---|---|
CassetteFormatError
|
if an |
CassetteEntry ¶
Bases: SchemaModel
One recorded call (one line of a cassette file).
For llm entries request is the LLMRequest
JSON (default fields omitted) and response the full
LLMResponse JSON (usage and raw included); both are
validated. For tool entries name is the tool name, request the
arguments by parameter name and response the JSON of the return value.
key is the sha256 hex digest compared in strict mode.
CassetteHeader ¶
Bases: SchemaModel
First line of a cassette file: format marker, version and free metadata.
CassetteLLM ¶
CassetteLLM(cassette: Cassette, client: LLMClient | None = None, *, model: str | None = None, provider: str | None = None)
An LLMClient answering from a Cassette.
Created by Cassette.wrap_llm. Attributes not defined here are
looked up on the real client (when there is one).
model
instance-attribute
¶
Default model (for span names); see Cassette.wrap_llm.
provider
instance-attribute
¶
Provider name (span attribute); see Cassette.wrap_llm.
complete ¶
complete(request: LLMRequest) -> LLMResponse
Play back the recorded response, or call (and maybe record) the real client.
acomplete
async
¶
acomplete(request: LLMRequest) -> LLMResponse
Async variant of complete (calls the real client's acomplete).
ReplayDivergence ¶
Bases: SchemaModel
One call where a replay differs from the recording.
*_request are the request JSON documents as stored in a cassette entry
(an LLMRequest without defaults for LLM calls, the
arguments by parameter name for tool calls); None with a None key
when that side has no call at index. diff is a bounded unified diff
of the two (for tool calls, of {"name", "arguments"}), see
request_diff. Build one with create.
Attributes:
| Name | Type | Description |
|---|---|---|
kind |
EntryKind
|
|
index |
int
|
Call index (per kind, 0-based), as in the cassette. |
reason |
DivergenceReason
|
See |
mode |
CassetteMode | None
|
Cassette mode during playback; |
expected_name |
/ actual_name
|
Tool names ( |
expected_key |
/ actual_key
|
Request keys (sha256); |
expected_request |
/ actual_request
|
The request JSON of each side. |
diff |
str
|
The unified diff ( |
diff_truncated |
bool
|
Whether |
span_id |
str | None
|
The replayed |
create
classmethod
¶
create(kind: EntryKind, index: int, *, expected_request: JsonValue = None, actual_request: JsonValue = None, expected_key: str | None = None, actual_key: str | None = None, expected_name: str | None = None, actual_name: str | None = None, mode: CassetteMode | None = None, span_id: str | None = None, max_lines: int = DIFF_MAX_LINES, max_line_chars: int = DIFF_MAX_LINE_CHARS) -> ReplayDivergence
Build a divergence, deriving reason and the bounded diff.
A side whose key is None has no call at index (extra_call
when the recording lacks it, missing_call when the replay does).
summary ¶
One line describing the divergence, e.g. llm call #2: request differs ....
to_dict ¶
JSON-ready dict (for reports and trace metadata).
include_requests=False leaves out the two full requests (the diff and
the keys remain), which keeps the dict small; the replayed request is
also the input of the span span_id.
CassetteError ¶
Bases: Exception
Base class for every cassette error.
CassetteFormatError ¶
Bases: CassetteError, ValueError
A cassette file (or a trace given to Cassette.from_trace) is invalid.
For files, the message names the path and the 1-based line number.
CassetteMissError ¶
CassetteMissError(*, kind: EntryKind, index: int, mode: CassetteMode, expected: CassetteEntry | None, actual_request: JsonValue, actual_key: str, actual_name: str | None = None, divergence: ReplayDivergence | None = None)
Bases: CassetteError
A call during playback has no usable recorded counterpart.
Raised in strict mode when the recorded call at this index has a
different request key (or a different tool name), and in strict and
lenient mode when there is no recorded call at this index at all. In
fallthrough mode a miss calls the real client instead.
The message ends with a unified diff of the recorded and actual requests;
divergence holds the same information as a
ReplayDivergence (for reports).
Attributes:
| Name | Type | Description |
|---|---|---|
kind |
EntryKind
|
|
index |
The call index (per kind, 0-based) that missed. |
|
mode |
CassetteMode
|
The cassette mode at the time of the call. |
expected |
The recorded entry at |
|
actual_request |
The request JSON of the call being made (same shape as
|
|
actual_key |
Its key (sha256 of the normalised request). |
|
actual_name |
The tool name for tool calls, |
|
divergence |
The |
expected_request
property
¶
The recorded request JSON, or None if nothing was recorded at index.
tool_key ¶
Cassette key of a tool call: sha256 of the canonical JSON of name and arguments.
arguments is the JSON of the call's arguments by parameter name (what a
tool_call span records as input).
find_divergence ¶
find_divergence(recorded: Trace, replayed: Trace, *, kind: EntryKind = 'llm') -> ReplayDivergence | None
First call of kind where replayed departs from recorded, or None.
The calls of each trace are selected and keyed exactly like
Cassette.from_trace <agentlens.replay.Cassette.from_trace> does
(llm_call or tool_call spans in start order; failed, unfinished and
tool-nested spans skipped), then compared index by index: a different
request key or tool name gives request_changed/tool_changed, a call
only in replayed extra_call and a call only in recorded
missing_call. span_id is the replayed span (None for a
missing call). Works for any two traces, e.g. a recorded run and a live
replay against another model (FR-R2).
.. code-block:: python
divergence = find_divergence(baseline_trace, new_trace)
if divergence is not None:
print(divergence) # summary + unified diff of the two requests
Raises:
| Type | Description |
|---|---|
CassetteFormatError
|
if an |
request_diff ¶
request_diff(expected: JsonValue, actual: JsonValue, *, expected_label: str = 'recorded', actual_label: str = 'replayed', present: tuple[bool, bool] = (True, True), max_lines: int = DIFF_MAX_LINES, max_line_chars: int = DIFF_MAX_LINE_CHARS) -> tuple[str, bool]
Unified diff of two request JSON documents; return (diff, truncated).
Both documents are pretty-printed as canonical JSON (sorted keys, 2-space
indent) before diffing, so key order never shows up as a change. A side
marked absent in present (no call at all) diffs as an empty document.
The result is empty when the documents are equal; otherwise it keeps at
most max_lines lines of at most max_line_chars characters, with a
final ... (N more diff lines) marker when lines were dropped.
truncated tells whether anything (lines or line ends) was cut.