Record, replay, and time-travel-debug LLM agent runs.
Project description
rewind
Record, replay, and time-travel-debug LLM agent runs.
Agents fail at step 37 of 40 and all you have is logs. rewind records every
LLM and tool call of a run to a portable trace file, replays the run
deterministically offline (no API key, $0), lets you fork a run at any step
to test an intervention, and turns recorded traces into free CI tests.
- Zero dependencies. The core is pure stdlib.
- Crash-safe. Events are flushed per call; a crashed run still leaves a usable trace.
- Divergence as a feature. When a replayed run escapes its trace, the error tells you the exact step, with a diff.
Install
pip install agent-rewind # import name: rewind
Zero runtime dependencies. Python 3.10+.
Quickstart
import rewind
@rewind.llm
def call_model(**request):
return client.chat.completions.create(**request)
@rewind.tool
def search(query: str) -> dict:
return search_api(query)
@rewind.memory
def recall(query: str) -> list[str]:
return vector_store.query(query) # external state reads are events too
# 1. Record a live run
with rewind.record("run.rewind"):
agent.run("find me a flight")
# 2. Replay it -- fully offline, deterministic, free
with rewind.replay("run.rewind"):
agent.run("find me a flight")
# 3. Time-travel: history up to step 23, live (with your fix) after
with rewind.fork("run.rewind", at=23, save_as="fixed.rewind") as session:
agent.run("find me a flight")
print(session.new_events) # what happened after the intervention
Zero code changes: auto-patching
Don't want decorators? Patch the SDK itself — works with any framework
built on openai, anthropic, litellm, mistralai, cohere,
google-genai, or mcp (smolagents, LangChain, LangGraph, the OpenAI
Agents SDK, pydantic-ai, etc.):
import rewind
rewind.patch_openai() # or patch_anthropic(), patch_litellm(),
# patch_mistral(), patch_cohere(),
# patch_gemini(), patch_mcp(),
# or auto_patch() for all installed
with rewind.record("run.rewind"):
agent.run("task") # unmodified framework agent
with rewind.replay("run.rewind"):
agent.run("task") # same run, offline; responses are revived
# into real SDK types, so resp.choices[0]
# works identically
patch_openai() covers chat.completions .create, .parse (structured
output), .stream(...), and with_raw_response (on both .create and
.parse — the path LangChain/LangGraph use for structured output). It also
covers the Responses API — responses.create (streaming and not),
responses.parse (structured output), the responses.stream(...) helper, and
with_raw_response on both — which is what the OpenAI Agents SDK uses by
default.
patch_anthropic() covers messages.create (including with_raw_response)
and the messages.stream(...) helper (sync + async, with
get_final_message()), plus the beta Messages namespace
(beta.messages.create, sync + async, streaming + with_raw_response) that
pydantic-ai and other frameworks use by default.
Embeddings are recorded too — patch_openai() covers
embeddings.create (sync + async + with_raw_response; the retrieval half
of a RAG agent previously embedded LIVE during replay), and patch_litellm()
covers litellm.embedding/aembedding. Native providers:
patch_mistral() (chat complete/stream, embeddings.create, sync +
_async), patch_cohere() (v2 chat, chat_stream, embed, rerank on
ClientV2/AsyncClientV2, sync + async — construct Cohere clients after
patching; the SDK re-binds chat methods per instance at construction — plus
the legacy v1 Client/AsyncClient embed/rerank, still the integration
path for older agent code; v1 chat is deliberately not covered, a
deprecated endpoint), and patch_gemini()
(google-genai generate_content, generate_content_stream, embed_content,
sync + client.aio; the legacy google-generativeai package is not
covered).
Groq, Together AI, and Fireworks are natively covered too — each ships
its own dedicated SDK (groq, together, fireworks-ai) built by the same
Stainless codegen openai's own SDK uses, so each patcher is a thin
instantiation of the same _Surface machinery: patch_groq() (chat. completions.create incl. stream=True/with_raw_response, plus
embeddings.create) and patch_together() (same shape; Together's codegen
suffixes resource classes with Resource and names its embedding type
Embedding, not CreateEmbeddingResponse — construct clients after
patching for both, same instance-rebinding rule as Cohere above).
patch_fireworks() covers chat only — Fireworks ships no embeddings
resource at all — and has its own naming trap: fireworks.types. CompletionCreateResponse (top-level) is the legacy /v1/completions
type; the real chat completion type of the same short name lives under
fireworks.types.chat. All three needed one shared fix along the way: Groq/
Together/Fireworks's newer Stainless codegen stamps the raw-response header
value "raw", where openai's own SDK stamps "true" — the shared
with_raw_response detection all four patchers use now accepts both.
patch_mcp() records Model Context Protocol
ClientSession.call_tool/list_tools/read_resource. Provider SDKs are
optional installs: pip install agent-rewind[openai] (or [anthropic],
[mistral], [cohere], [gemini], [groq], [together], [fireworks],
[mcp]).
Already covered via
patch_openai()+base_url— but only when your code constructs anopenai.OpenAI/AsyncOpenAI/AzureOpenAIclient. The patch is class-level (it replacesCompletions.createetc. on the class, not per instance), so it doesn't care whatbase_urlpoints at or whether the class is subclassed — verified end-to-end forAzureOpenAIspecifically (constructed withazure_endpoint/api_version, a genuinely different request path from public OpenAI): real MockTransport round-trip, offline replay, zero code changes. The same mechanism covers any provider reached by pointing the openai SDK'sbase_urlat it: DeepSeek, OpenRouter, Ollama's/v1, vLLM are safe bets here — that's their primary documented Python access pattern, with no competing first-party SDK pulling integrations away from it.The trap: Groq, Together AI, Fireworks, and xAI each also ship their own dedicated Python package (
groq,together,fireworks-ai,xai-sdk) with its own client classes — a completely separate hierarchypatch_openai()never touches. Agent code that constructs one of these directly needs the matching native patcher:patch_groq()/patch_together()/patch_fireworks()above (pip install agent-rewind[groq], etc., not relying on thebase_urlpath). Check which client your LangChain setup actually builds before assuming — confirmed by reading each integration's source:langchain_groq.ChatGroqandlangchain_fireworks.ChatFireworksconstruct the realgroq/fireworksSDK clients (patch_groq()/patch_fireworks()apply), butlangchain_together.ChatTogetherconstructs a plainopenai.OpenAI(base_url=<together's endpoint>)— it never touches thetogetherpackage at all, so it's already covered bypatch_openai()andpatch_together()is irrelevant to it (only matters if you call thetogetherSDK directly). xAI is the one still in the trap:xai_sdk.Clienthas its own class tree rooted atxai_sdk.client.BaseClient(confirmed by inspection — not a subclass ofOpenAI), and it's gRPC/protobuf-based end to end, not httpx —chat.create()doesn't even perform an RPC itself (it returns a stateful builder; the network call happens later in.sample()/.stream()), and responses are raw protobuf messages, not pydantic models. That's a genuinely different mechanism from every other provider here, not just a new instantiation of the existing one — designed indocs/xai-design.md, not yet built.
Not covered: batch/job-based embedding APIs (OpenAI's Batch API, Cohere's
embed_jobs) — upload-a-file-then-poll-for-results workflows, architecturally distinct from the single request/response calls the_Surfacemechanism targets. Wrap the polling loop's result-fetch step with@rewind.toolif you need one recorded.Note the
[embeddings]extra is unrelated to provider embedding recording above — it installssentence-transformersforuse_embedding_similarity(), the semantic fuzzy-matching backend (see Replay matching strategies). Recordingembeddings.create()calls needs no extra at all; it's covered bypatch_openai()/patch_litellm()like any other surface.
Patching is idempotent, reversible (unpatch = patch_litellm()), and a
no-op passthrough when no session is active. Overlapping consumers are
reference-counted: if two call sites patch the same provider, interception
survives until the last unpatch(). rewind.integrations.patched() is the
context-manager form.
Async and streaming
Async is supported everywhere: the decorators wrap async def functions
transparently, and the patches cover AsyncOpenAI and litellm.acompletion
too. Sessions live in a contextvars.ContextVar, so concurrent asyncio
tasks inherit the right session and unrelated tasks don't leak into each
other's traces.
Raw OS threads need the context. Because the session lives in a
ContextVar, a call made in a barethreading.Threador aThreadPoolExecutorworker runs with an empty context — it is neither recorded nor served, so during replay it would hit the live API. asyncio propagates context for you (asyncio.gather,asyncio.to_threadboth work); for manual threads, copy the context in:contextvars.copy_context().run(fn)(orctx.runas the executor'sinitializer). This is a Python-level constraint, not a rewind choice.
stream=True works on both paths, and recording is lazy: chunks are
written to the trace as your agent consumes them, not up front. Abort a
stream halfway (break, .close(), an exception) and the HTTP connection
closes with it — you stop paying for tokens, and the trace records exactly
the chunks the agent saw, marked truncated. At replay time the same chunks
come back as a fake stream of real ChatCompletionChunk objects —
async for over a replayed stream is indistinguishable from the live one.
(Content is identical; only chunk timing differs while recording.)
Redacting secrets
def scrub(payload): # any callable: payload -> payload
if isinstance(payload, dict):
payload.pop("api_key", None)
return payload
with rewind.record("run.rewind", redact=scrub):
...
Redaction runs before anything touches disk.
record() also takes meta={"git_sha": ..., "ticket": ...} — an arbitrary
dict stored in the trace header, shown by rewind show and available as
trace.meta after Trace.load().
Inspect traces from the terminal:
$ rewind show run.rewind -v # dump every step
$ rewind show run.rewind -i # step through it: full-screen TUI on a
# terminal, line stepper otherwise
$ rewind show run.rewind --tui # force the full-screen curses browser
$ rewind stats run.rewind # counts by kind, errors, time, tokens + est. cost
$ rewind stats run.rewind --no-cost # tokens only, no price estimate
$ rewind stats run.rewind --price gpt-4o=2.5/10 # override a per-1M-token price
$ rewind diff a.rewind b.rewind # where did two runs diverge? exit 1 if they did
$ rewind diff a.rewind b.rewind -v -c 3 # full previews, 3 context steps per change
rewind diff aligns two runs semantically (LCS over step identities), marks
each step equal/changed/inserted/deleted, and points at the first
divergence — the step where the model answered differently and everything
downstream followed. The exit code makes it a one-line CI regression gate.
Full-screen stepper (TUI)
rewind show --tui (and -i on a real terminal) opens a two-pane curses
browser: a scrollable event list on the left, the selected step's
request/response detail on the right. Keys: j/k (or arrows) move, g/G
jump to top/bottom, PgUp/PgDn page, / filters by substring, enter
expands the detail into pretty-printed JSON, q quits. It's stdlib-only and
degrades gracefully — no terminal (a pipe, CI, rewind show | less) or no
curses falls back to the line stepper automatically; --no-tui forces it.
Token & cost accounting
rewind stats sums the tokens each model actually used (read from the
recorded usage) and estimates the dollar cost from a built-in price table:
tokens & cost (estimated):
model calls input output cost
gpt-4o-2024-08-06 2 2400 680 $0.0128
total 2 2400 680 $0.0128
* cost is an ESTIMATE from built-in list prices (USD per 1M tokens) …
Tokens are exact; the dollar figure is a best-effort estimate (prices change
and vary by contract) that you can override with --price MODEL=IN/OUT
(repeatable) or --prices-file prices.json, or hide with --no-cost. Calls
whose recording carries no usage (a stream without stream_options= {"include_usage": True}, or an errored call) are reported as unknown, never
counted as zero. Programmatic access: rewind.account(trace) returns an
Accounting (per-model ModelUsage, totals, unpriced_models).
Big traces & multimodal runs
Trace size is driven by payload bytes, not step count — 5,000 text events (~22 MB) load in ~0.1 s and strict-replay in ~0.5 s. What blows traces up is multimodal content re-sent every turn. Three tools, all optional:
with rewind.record("run.rewind", externalize=64_000): # bytes threshold
agent.run("describe these screenshots")
externalize=stores any payload leaf ≥ the threshold as a content-addressed file underrun.rewind.blobs/and leaves a hash marker in the trace line. A vision agent re-sending one 1 MB image for 200 turns writes a 72 KB trace plus one blob (measured intests/test_scale.py) instead of ~200 MB. Replay identity is unchanged — fingerprints are computed over the full content — and replay serves the original values back. The trace becomes a file + blob directory pair: keep them together (a missing blob fails loudly with the sha and expected path). Marks the trace format v2; older rewinds refuse it cleanly.- Lazy loading is automatic:
replay(),fork(), and the CLI open traces viaTrace.open(), which keeps only a ~200-byte skeleton per event in memory and reads bodies back on demand — replay memory is O(events), not O(bytes), even for old v1 traces with inline payloads. (Trace.load()keeps the fully-materialized behavior.) rewind compact run.rewindgzips a finished trace (repeated-history JSONL typically shrinks 10–50×); everything loads.rewind.gztransparently. Live recording stays uncompressed on purpose — per-line flush is the crash-safety guarantee.
Request delta-encoding was considered and rejected: it would break the format's core properties (human-inspectable, git-diffable, torn-line tolerant — one corrupt line would poison every later request), and blobs + compression already deliver the win without the semantic risk.
Replay matching strategies
| mode | behavior | use for |
|---|---|---|
strict |
request must match the recording exactly; drift raises DivergenceError with a diff |
CI / regression tests |
ordered |
serve by position, warn on payload drift | exploratory debugging after code changes |
fuzzy |
serve the most similar recorded request within a look-ahead window; warns on approximate matches and skipped steps | replaying old traces against refactored agents |
parallel |
serve by request identity (exact fingerprint), order-independent; identical duplicates served FIFO | agents that fire tools concurrently (asyncio.gather, asyncio.to_thread) |
Fuzzy matching never crosses kind (llm/tool) or call name, never rewinds
to a consumed event, and refuses matches below its threshold (default 0.6,
searched within an 8-event look-ahead window). Both knobs are tunable —
match= also accepts a configured matcher instance:
with rewind.replay("run.rewind", match=rewind.FuzzyMatcher(threshold=0.8, window=16)):
...
(StrictMatcher, OrderedMatcher, and ParallelMatcher are exported too,
and anything implementing the Matcher protocol works — the strategy layer
is deliberately pluggable. One extra contract for fork(): a custom matcher
that declares positional = False must also provide
serve_prefix(cursor, kind, name, fp, request, trace, limit) returning the
recorded event to serve or None to go live — non-positional matching has no
cursor to compare against the fork boundary; see FuzzyMatcher/
ParallelMatcher for reference implementations.) Similarity is a pluggable backend
(rewind.set_similarity(fn)); the default is a zero-dependency lexical
scorer. For semantic matching — paraphrased prompts that share meaning but
not words — install the extra and switch backends in one line:
# pip install agent-rewind[embeddings]
rewind.use_embedding_similarity() # cosine over sentence embeddings
The default model is all-MiniLM-L6-v2 (override with model_name=);
embeddings are LRU-cached per unique request (cache_size=2048), so long
traces encode each distinct request once.
Parallel matching exists because concurrent tool calls complete in a
different order every run; positional matching would call that divergence.
It is still exact (payload drift raises DivergenceError, like strict) —
just order-free.
Testing agents with recorded traces
def test_flight_agent_regression():
backend.impl = rewind.NeverCalled() # prove no live calls happen
with rewind.replay("traces/flight.rewind"):
result = agent.run("find me a flight")
assert "SFO" in result["final"]
ScriptedLLM (a scriptable fake model) and NeverCalled are exported for
exactly this: deterministic agent tests without an API key. Script items
can be plain values (returned), Exceptions (raised — simulate provider
failures), or callables (called with the request); the double tracks
.calls and .remaining, and raises ScriptExhausted past the end.
For CI, replay(path, require_full_consumption=True) turns leftover recorded
events from an UnconsumedEventsWarning into a hard failure — every recorded
event must actually be served to the agent for the run to pass. That
covers both a run that ended earlier than the recording and a match="fuzzy"
replay that skipped past events without serving them.
Breakpoints: stop the agent mid-flight
with rewind.replay("run.rewind", break_at=2):
agent.run("find me a flight")
When step 2 is served, the agent pauses inside breakpoint() — its real
frames, message lists, and local state live on the stack, while the model
behind it is a file. Inspect anything, for as long as you like, at $0, and
re-run to this exact point forever. on_step=callback fires with each
served Event (its .seq, .kind, .name, .request, .response, ...)
if you want programmatic hooks instead. Because replay is deterministic and
in-process, ordinary pdb breakpoints in your own agent code also hit the
same way every run. break_at goes through the standard breakpoint()
machinery, so PYTHONBREAKPOINT=ipdb.set_trace picks your debugger and
PYTHONBREAKPOINT=0 disables the pause without editing code.
The pytest plugin: golden traces in one marker
@pytest.mark.rewind_trace("traces/flight.rewind")
def test_flight(rewind_session):
result = run_agent("find me a flight")
assert "SFO" in result["final"]
Plain pytest replays the trace offline. pytest --rewind-record runs
marked tests live and (re-)records their golden traces — the cassette
workflow with one flag. Relative paths resolve next to the test file, and
the marker takes a matching strategy:
@pytest.mark.rewind_trace("traces/flight.rewind", match="fuzzy").
It also forwards normalize= (drop volatile fields — timestamps, uuids —
from each call's replay identity, applied at record and replay alike) and
redact= (scrub the recorded golden before it touches disk), mirroring
rewind.record()/replay().
LLM / tools / memory: what gets captured
rewind records the nondeterminism boundary, not your agent:
- LLMs —
@rewind.llmwraps any provider callable; requests/responses are opaque JSON. Or skip the decorator:rewind.auto_patch()intercepts theopenai,anthropic, andlitellmSDKs directly (zero code changes, reversible, passthrough when no session is active);auto_patch("openai")patches only the named providers and raises if one is missing. - Tools —
@rewind.toolwraps any side-effecting callable: HTTP, DBs, shell. For Model Context Protocol servers,patch_mcp()recordsClientSessiontool calls directly. Exceptions are recorded and replayed too (ReplayedError); a tool result flaggedisErroris preserved as a normal return value, not turned into a raised exception. - Memory — in-process state (message history, scratchpads) needs no
capture: it's deterministic given the same llm/tool responses. External
memory (vector stores, Redis) is a nondeterminism source — wrap reads
with
@rewind.memory, sostats/diffcan separate "the model changed its answer" from "retrieval returned different context".
All three decorators work on async def functions unchanged and accept a
name= override (@rewind.tool(name="search")) when the qualified
function name isn't the identity you want recorded.
Runnable examples
All demos run offline against local stub servers — no API key needed
unless you point them at a real endpoint. Per-example requirements:
time_travel_demo is pure stdlib; real_agent_demo and
async_streaming_demo need pip install openai; smolagents_demo needs
smolagents[litellm]; langgraph_demo needs langgraph langchain-openai;
openai_agents_demo and parallel_agents_demo need openai-agents.
examples/real_agent_demo.py— the real OpenAI SDK against a local stub server: records a tool-calling run, kills the server, replays offline, then diffs a regressed run.BASE_URL/MODEL/OPENAI_API_KEYenv vars point it at Ollama or a hosted endpoint.examples/smolagents_demo.py— an unmodified smolagents agent captured via onepatch_litellm()call.REPLAY=1re-runs it offline;USE_OLLAMA=1 MODEL=llama3.2runs it against a local Ollama model.examples/async_streaming_demo.py— a real AsyncOpenAI client streaming over HTTP (SSE), captured bypatch_openai()with zero code changes, then replayed chunk-for-chunk with the server gone.examples/langgraph_demo.py— an unmodified LangGraph ReAct agent (create_react_agent+ChatOpenAI) recorded live and replayed with the server down. Exercises thewith_raw_responsepath LangChain uses internally.examples/openai_agents_demo.py— an unmodified OpenAI Agents SDK agent (Agent+Runner, fully async) recorded and replayed offline via the same singlepatch_openai()call.examples/parallel_agents_demo.py— the stress test: three agents running concurrently (asyncio.gather) with parallel tool_calls and multi-turn data dependencies, against a randomly-latent server so the trace order is scrambled. Replayed offline withmatch="parallel", which serves each request by identity regardless of interleaving.examples/time_travel_demo.py— a 12-step pipeline agent recorded once, then interrogated: fork at step 5 with a changed input and watch the cascade indiff; fork at step 11 and reuse 11 of 12 steps; tamper with a replayed input and see strict matching raiseDivergenceErrorwhile fuzzy matching absorbs it with a warning.
API reference
Everything rewind exports, one line each.
Sessions (context managers — each yields a Session)
| API | what it does |
|---|---|
record(path, *, meta=None, redact=None, normalize=None, externalize=None) |
record a live run to path, flushed per event; meta dict lands in the trace header; redact scrubs stored payloads; normalize drops volatile fields from each call's fingerprint (pass the same fn to replay/fork); externalize (byte threshold) stores big payloads content-addressed under <path>.blobs/ — see Big traces |
replay(path, *, match="strict", require_full_consumption=False, on_step=None, break_at=None, redact=None, normalize=None) |
replay offline; match is a strategy name from the table above or a configured Matcher instance; require_full_consumption=True fails the run if the trace isn't fully consumed; redact scrubs any live tail events; normalize must match the one used at record |
fork(path, *, at, match="strict", save_as=None, redact=None, normalize=None) |
serve steps [0, at) from the trace, then go live; at=len(trace) extends a finished run; save_as writes a new trace whose header carries forked_from provenance (and is itself replayable from step 0); redact scrubs the live-suffix events the fork records; normalize must match the one used at record |
current_session() |
the active Session (or None); lives in a contextvars.ContextVar, so asyncio tasks inherit it |
Session |
what the context managers yield: .trace, .new_events (live fork-suffix events), .cursor, .mode |
Capture
| API | what it does |
|---|---|
@rewind.llm / @rewind.tool / @rewind.memory |
wrap any sync or async callable; optional name= overrides the recorded call name |
patch_openai() |
class-level patch of Completions/AsyncCompletions .create, .parse, .stream(...), and with_raw_response, plus the Responses API (Responses/AsyncResponses .create, .parse, .stream(...), with_raw_response) the OpenAI Agents SDK uses by default, plus embeddings.create (sync + async + streaming + raw); returns an unpatch() |
patch_anthropic() |
patches Messages/AsyncMessages .create (incl. stream=True) and the messages.stream(...) helper (sync + async); returns an unpatch() |
patch_litellm() |
patches litellm.completion/acompletion and litellm.embedding/aembedding (sync + async + streaming); returns an unpatch() |
patch_mistral() |
patches mistralai chat.complete/chat.stream and embeddings.create (sync + the SDK's *_async twins); returns an unpatch() |
patch_cohere() |
patches cohere v2 chat, chat_stream, embed, rerank on ClientV2/AsyncClientV2 (construct clients AFTER patching) plus legacy v1 Client/AsyncClient embed/rerank (v1 chat not covered); returns an unpatch() |
patch_gemini() |
patches google-genai models.generate_content, generate_content_stream, embed_content (sync + client.aio); returns an unpatch() |
patch_mcp() |
patches mcp.ClientSession .call_tool (a tool event), .list_tools (tool), .read_resource (memory); returns an unpatch() |
patch_thread_pool() |
copy the submitting thread's context into stdlib ThreadPoolExecutor workers so calls in submit/map are recorded (replay with match="parallel"); returns an unpatch(). asyncio & context-copying frameworks (LangChain) don't need it |
auto_patch(*providers) |
patch every installed provider SDK, or just the named ones ("openai", "anthropic", "litellm", "mistral", "cohere", "gemini", "mcp"); returns one unpatch() for everything |
rewind.integrations.patched(*providers) |
context-managed auto_patch: patch on entry, restore on exit |
All patchers are idempotent, reversible, reference-counted (the original is restored only when the last overlapping consumer unpatches), and a no-op passthrough when no session is active.
Traces and events
| API | what it does |
|---|---|
Trace |
a recorded trace: Trace.open(path) (lazy — skeleton in memory, bodies hydrate on access; what replay/fork/CLI use), Trace.load(path) (eager), .dump(path), .events, .meta, .version, len(), iterable |
Event |
one step: .seq, .kind (llm/tool/memory), .name, .fingerprint, .request, .response, .error, .duration_ms, .ts, .live |
Diffing (programmatic — what rewind diff uses)
| API | what it does |
|---|---|
diff_traces(a, b) |
paths or Trace objects → TraceDiff via LCS alignment over step identities |
TraceDiff |
.entries, .diverged, .first_divergence, .summary() |
DiffEntry |
one aligned row: .op (equal/changed/deleted/inserted), .a, .b, .similarity, .step |
render_text(diff, *, context=1, verbose=False) |
the human-readable report the CLI prints |
Accounting (programmatic — what rewind stats uses)
| API | what it does |
|---|---|
account(trace, *, prices=None) |
walk a trace's llm events → Accounting; prices (a {model: ModelPrice} map) is merged over the built-in table |
Accounting |
.per_model (sorted ModelUsage list), .total_input_tokens, .total_output_tokens, .total_tokens, .total_cost, .calls_without_usage, .unpriced_models |
ModelUsage |
per model: .model, .calls, .calls_with_usage, .calls_without_usage, .input_tokens, .output_tokens, .total_tokens, .priced, .cost |
ModelPrice(input, output) |
USD per 1,000,000 tokens |
DEFAULT_PRICES |
the built-in, approximate, overridable price table |
Similarity (fuzzy matching backend)
| API | what it does |
|---|---|
StrictMatcher / OrderedMatcher / FuzzyMatcher(threshold=0.6, window=8) / ParallelMatcher |
the strategy classes behind the match= names; pass a configured instance (or your own Matcher implementation) to replay/fork |
set_similarity(fn) |
install any (a, b) -> float in [0, 1] as the backend |
lexical_similarity |
the zero-dep default: difflib ratio blended with token-set Jaccard |
use_embedding_similarity(model_name=..., *, cache_size=2048) |
semantic cosine over sentence embeddings; requires agent-rewind[embeddings] |
Test doubles
| API | what it does |
|---|---|
ScriptedLLM(script) |
fake model that plays a script; items are values (returned), Exceptions (raised), or callables (called with the request); tracks .calls, .remaining |
NeverCalled(label=...) |
raises LiveCallDuringReplay if any live call reaches it — proof a replay stayed offline |
Errors and warnings
| API | raised / emitted when |
|---|---|
RewindError |
base class for all rewind errors |
DivergenceError |
replay can't match an incoming call; carries .step, .reason, .expected, .got plus a unified diff and a fix hint |
ReplayedError |
replay re-raises an error that occurred at record time (.original_type, .original_message, .step). When the original exception class resolves, the raised instance also subclasses it (and carries scalar attrs like .status_code), so an agent's except ProviderError: retry() recovery reproduces on replay — including errors raised mid-stream, which replay after the same chunks the agent originally consumed |
TraceFormatError |
missing, corrupt, empty, or newer-format trace file |
ScriptExhausted |
a ScriptedLLM ran out of scripted responses |
LiveCallDuringReplay |
a NeverCalled double was reached during replay |
LossySerializationWarning |
a value was recorded as its repr (emitted at record time) |
ReviveFallbackWarning |
replay served a raw dict because the recorded payload couldn't be revived into its SDK type (SDK missing at replay time, or its internals changed); fires once per payload type |
PatchGapWarning |
patch_*() could not hook an SDK surface it expected (the installed SDK's layout moved), or detected at patch time that replay revival would degrade; fires once per gap |
FuzzyMatchWarning |
fuzzy replay served an approximate or out-of-position match |
ReplayDriftWarning |
match="ordered" served a response for a drifted request |
UnconsumedEventsWarning |
replay ended with recorded events left unconsumed |
CLI
| command | what it does |
|---|---|
rewind show <trace> [-v] [-i] |
list every step; -v adds request/response previews; -i opens the interactive stepper (n/p/j <step>/q) |
rewind stats <trace> |
recording timestamp, event counts by kind, error count, total recorded live time, first error |
rewind diff <a> <b> [-v] [-c N] |
semantic diff with first-divergence report; -c sets context steps; exit code 1 on divergence (CI gate) |
rewind compact <trace> [-o PATH] |
gzip a finished trace for archival (10–50× on repeated-history runs); loads transparently everywhere |
Design notes
- Traces are JSON-lines: human-readable, git-diffable, versioned header, tolerant of a torn final line (recorder crash), strict about corruption elsewhere.
- Non-JSON-able values are recorded as their
reprwith aLossySerializationWarningat record time — you find out about fidelity loss while you can still fix it. (byteskeep their exact contents, stored tagged as UTF-8 or hex rather than a repr; pydantic models and dataclasses are converted structurally.) - Determinism of identity: a call's fingerprint must be identical in every
process, or a valid record-now/replay-later run would spuriously diverge.
Anything whose default string form is process-dependent is therefore
canonicalized before it reaches the fingerprint: a
set/frozensetbecomes a sorted list (its native order is hash-derived, and CPython randomizes string hashing per process), memory addresses in areprare normalized away, and a non-string dict key (afrozenset, a custom object) is stringified through the same ladder rather than a rawstr(key). Like tuple→list, set→sorted-list is a type change, not a value change — a tool that returns a set replays as a sorted list. - Secrets: pass
redact=torecord(); redaction runs before anything touches disk. - Concurrency: the session lock covers only the stateful core — the replay
match decision, reserving a call's seq, and appending its finished event.
Request canonicalization (including your
redact=callable) runs before it, and the live call plus replay delivery (on_step, abreak_atdebugger pause, raising a replayed error) run after it — so concurrent calls (async tasks or threads) really overlap, a slow synchronous call can't stall the event loop, and sitting at a breakpoint doesn't freeze other threads' calls. Events land in completion order carrying call-start seqs and are re-sorted by seq on load; replaying concurrent calls deterministically is whatmatch="parallel"is for. The active session propagates via aContextVar(asyncio-native); raw threads must copy the context in (see the async section). - Redaction and identity: the redactor runs on the stored request/response
and on recorded error dicts, but a call's replay identity (fingerprint)
is taken over the un-redacted request — so scrubbing a payload never makes
a trace fail to replay. Pass
redact=tofork()/replay()too and it scrubs the live events they record for a fork's suffix (or any live tail), so a forked run's fresh calls are redacted the same as the recorded prefix.
Compatibility
Auto-patching necessarily hooks SDK internals — class methods, the
instance-attribute create the openai streaming wrapper sets, and the SDKs'
private lenient parsers (openai._models.construct_type /
anthropic._models.construct_type, used to revive recorded payloads exactly
the way the SDK parses wire responses). Those internals are not stable public
API, so rewind is explicit about what happens when they move:
- Tested against: openai 1.40 → 2.45, anthropic 0.18 → 0.116,
litellm 1.91, mistralai 1.5 → 2.6 (both the 1.x flat and 2.x namespaced
layouts are exercised), cohere 6.1 → 7.0, google-genai 1.50 → 2.11, mcp
1.28 (the full provider test suite runs against BOTH ends of each range,
not just the newest — the version floors in
pyproject.toml's extras are the ones actually verified this way, not guessed; older releases either lack the targeted API — e.g. cohere's v2 client before ~5.9, or google-genai's mock-transport injection before ~1.50 — or, for cohere 5.9–5.13 specifically, have a self-inconsistentmodel_dump()/construct_type()round-trip for one field, an upstream SDK bug rather than a rewind gap). A scheduledsdk-canaryCI job re-runs the suite against the latest SDK releases to catch upstream movement early. - If a layout moves, nothing fails silently:
patch_*()emits aPatchGapWarningat patch time naming each surface it could not hook (and a revival canary warns ifconstruct_typeis gone); any call through an unhooked path additionally warns at call time; failed revival serves the recorded dict with aReviveFallbackWarninginstead of a mystery crash. - Old SDKs degrade quietly by design: a namespace that predates a feature (e.g. anthropic's beta Messages, the Responses API) is skipped without noise — warnings are reserved for layouts that should be there and aren't.
Known limitations
Honest edges for a v0.5 alpha — none silent:
with_streaming_response.create(stream=True)on the OpenAI Responses API IS recorded — this is how the OpenAI Agents SDK'sRunner.run_streamedstreams (raw SSE), and it now records and replays fully offline. Other raw-SSE paths (with_streaming_response.parse, and anthropic's raw streaming) are still passed through with aLossySerializationWarning; use.stream(...),create(stream=True), orwith_raw_responsethere. (.create,.parse,.stream, andwith_raw_responseare all covered.)- Raw stdlib thread pools need
patch_thread_pool(). A call in aconcurrent.futures.ThreadPoolExecutor/threading.Threadworker starts with an empty context and is neither recorded nor replayed (a one-timeThreadlessCallWarningfires — never silent). Callrewind.patch_thread_pool()to copy the submitting thread's context into workers; then those calls record and replay offline (usematch="parallel", since completion order is nondeterministic). asyncio tasks and well-behaved frameworks that copy context themselves (e.g. LangChain's ownContextThreadPoolExecutor, verified) already work without it. - Volatile per-run fields in the request need
normalize=(ormatch="ordered"/"fuzzy"). A timestamp, uuid, or per-run id stamped into a prompt (or a framework field like the OpenAI Agents SDK's randomprompt_cache_key) makes the replayed request never byte-match the recording, so defaultstrictmatching raisesDivergenceError. Passnormalize=fntorecord()/replay()/fork()— the same function on both — to drop those fields from a call's fingerprint before matching (the stored request keeps its real value); or replay withmatch="ordered"to serve by position. Verified end-to-end: a timestamped-prompt agent replays fully offline understrictwithnormalize, and the OpenAI Agents SDK undermatch="ordered". - Lexical fuzzy matching over-matches very short arguments. The default
match="fuzzy"similarity is lexical; for tiny requests the constant request scaffolding can push unrelated short tool args over the threshold. Raise the threshold (FuzzyMatcher(threshold=…)) or switch to embeddings (use_embedding_similarity()) for short/structured requests. - Anthropic
messages.stream()records the raw event stream. The SDK's synthesized higher-level events (text,thinking,citation, …) are derived from the raw events and carry cumulative snapshots, so they aren't recorded (that would bloat the trace quadratically). Iterating a replayed stream yields the raw events;text_stream,get_final_message(), andget_final_text()replay exactly — prefer them for offline replay. - A replayed error reconstructs a bounded surface, not the live exception.
except ProviderError:catches it, and it carries the recorded scalars (status_code,code,param,request_id, structuredbody) plus a synthetice.responseshim exposingstatus_code,headers(so retry-after backoff logic reproduces), and the error body via.text/.json(). Anything richer —e.response.request, a live connection, provider-specific lazy properties — was never recordable and raises anAttributeErrorthat says so, naming rewind. Loud, never wrong data. - Scale edges that remain (see Big traces & multimodal runs for what's
solved): a trace's
.blobs/directory must travel with the file — replay without it fails loudly, never silently; gzip-compacted traces load eagerly (no random access into a gzip stream); and record-time canonicalization still walks the full payload once per call (sha256 + JSON of a 1 MB image ≈ ~5 ms), which is the price of exact fingerprints. - A redactor must not RETURN rewind's internal marker sentinels. If a
redact=function returns a dict whose sole key is__bytes_utf8__,__bytes_hex__, or__rewind_literal__, that value is revived tobyteson replay. The redactor sees the JSON-able form (so it can scrub secrets inside pydantic models and objects), and its output is re-canonicalized without re-escaping — which can't tell a genuine bytes marker from a redactor-emitted look-alike. Don't emit those reserved keys from a redactor (return a normal string/dict); ordinary redaction is unaffected.
Status
v0.7 — everything below, plus big-trace support: blob externalization
(externalize=, format v2), lazy trace loading (Trace.open, automatic in
replay/fork/CLI), and rewind compact archival gzip; embeddings recording
(patch_openai()/patch_litellm()); native Mistral, Cohere (v2 + legacy v1
embed/rerank), Gemini (google-genai), Groq, Together AI, and Fireworks
providers, each a thin instantiation of the same _Surface mechanism (see
"The trap" above for what's still not covered); an 18-scenario real-agent
e2e battery (LangGraph/pydantic-ai, tool-calling, record→replay) proving
every native provider through an actual multi-step agent, not just direct
SDK calls.
v0.5 — record/replay/fork, strict/ordered/fuzzy/parallel matching,
rewind diff, full-screen TUI + line stepper, token/cost accounting in
rewind stats, pytest plugin, @rewind.memory, auto-patching for
OpenAI (chat.completions + Responses API, incl. .parse/.stream/with_raw_response),
Anthropic, litellm, and
MCP (sync + async + streaming), mid-flight breakpoints (break_at/on_step),
optional embedding similarity (agent-rewind[embeddings]).
Roadmap: xAI recording (designed — see docs/xai-design.md; gRPC/protobuf
and stateful, needs a fingerprint-from-instance-state extension to the
surface mechanism, not just a new instantiation of it), AWS Bedrock
recording (designed — see docs/bedrock-design.md; a botocore-level hook,
deliberately outside the surface mechanism), the legacy
google-generativeai package (the unified google-genai SDK is covered),
per-run cost budgets, HTML trace export.
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file agent_rewind-0.7.0.tar.gz.
File metadata
- Download URL: agent_rewind-0.7.0.tar.gz
- Upload date:
- Size: 271.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
675d8c008f27ea336ddde50cdafd57b34996bdce27e2309eb6eaa7a04d316140
|
|
| MD5 |
8ae417130e72792ae8462f68b7d4a8bd
|
|
| BLAKE2b-256 |
9d0fc3bc17dd74f21f3bc3d5d177752ef695b84014c5b5f9fe7b1b7919667521
|
Provenance
The following attestation bundles were made for agent_rewind-0.7.0.tar.gz:
Publisher:
release.yml on Abhi-2526/agent-rewind
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
agent_rewind-0.7.0.tar.gz -
Subject digest:
675d8c008f27ea336ddde50cdafd57b34996bdce27e2309eb6eaa7a04d316140 - Sigstore transparency entry: 2144873522
- Sigstore integration time:
-
Permalink:
Abhi-2526/agent-rewind@aab84c46760695689f85739c1c49e830019a02e8 -
Branch / Tag:
refs/tags/v0.7.0 - Owner: https://github.com/Abhi-2526
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@aab84c46760695689f85739c1c49e830019a02e8 -
Trigger Event:
push
-
Statement type:
File details
Details for the file agent_rewind-0.7.0-py3-none-any.whl.
File metadata
- Download URL: agent_rewind-0.7.0-py3-none-any.whl
- Upload date:
- Size: 108.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
872dd10e7c1a6b4f0887e61e5b807b24543da6014b693f7d66f227db1f326e18
|
|
| MD5 |
f357531593583b645f6af69dc540cbd7
|
|
| BLAKE2b-256 |
ed32705bebc347ba13177387e58e20bc33124626efd782ed40eb13e265700a96
|
Provenance
The following attestation bundles were made for agent_rewind-0.7.0-py3-none-any.whl:
Publisher:
release.yml on Abhi-2526/agent-rewind
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
agent_rewind-0.7.0-py3-none-any.whl -
Subject digest:
872dd10e7c1a6b4f0887e61e5b807b24543da6014b693f7d66f227db1f326e18 - Sigstore transparency entry: 2144873912
- Sigstore integration time:
-
Permalink:
Abhi-2526/agent-rewind@aab84c46760695689f85739c1c49e830019a02e8 -
Branch / Tag:
refs/tags/v0.7.0 - Owner: https://github.com/Abhi-2526
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@aab84c46760695689f85739c1c49e830019a02e8 -
Trigger Event:
push
-
Statement type: