Skip to main content

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 APIresponses.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 toopatch_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 an openai.OpenAI/AsyncOpenAI/AzureOpenAI client. The patch is class-level (it replaces Completions.create etc. on the class, not per instance), so it doesn't care what base_url points at or whether the class is subclassed — verified end-to-end for AzureOpenAI specifically (constructed with azure_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's base_url at 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 hierarchy patch_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 the base_url path). Check which client your LangChain setup actually builds before assuming — confirmed by reading each integration's source: langchain_groq.ChatGroq and langchain_fireworks.ChatFireworks construct the real groq/ fireworks SDK clients (patch_groq()/patch_fireworks() apply), but langchain_together.ChatTogether constructs a plain openai.OpenAI(base_url=<together's endpoint>) — it never touches the together package at all, so it's already covered by patch_openai() and patch_together() is irrelevant to it (only matters if you call the together SDK directly). xAI is the one still in the trap: xai_sdk.Client has its own class tree rooted at xai_sdk.client.BaseClient (confirmed by inspection — not a subclass of OpenAI), 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 in docs/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 _Surface mechanism targets. Wrap the polling loop's result-fetch step with @rewind.tool if you need one recorded.

Note the [embeddings] extra is unrelated to provider embedding recording above — it installs sentence-transformers for use_embedding_similarity(), the semantic fuzzy-matching backend (see Replay matching strategies). Recording embeddings.create() calls needs no extra at all; it's covered by patch_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 bare threading.Thread or a ThreadPoolExecutor worker 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_thread both work); for manual threads, copy the context in: contextvars.copy_context().run(fn) (or ctx.run as the executor's initializer). 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 under run.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 in tests/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 via Trace.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.rewind gzips a finished trace (repeated-history JSONL typically shrinks 10–50×); everything loads .rewind.gz transparently. 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.llm wraps any provider callable; requests/responses are opaque JSON. Or skip the decorator: rewind.auto_patch() intercepts the openai, anthropic, and litellm SDKs 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.tool wraps any side-effecting callable: HTTP, DBs, shell. For Model Context Protocol servers, patch_mcp() records ClientSession tool calls directly. Exceptions are recorded and replayed too (ReplayedError); a tool result flagged isError is 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, so stats/diff can 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_KEY env vars point it at Ollama or a hosted endpoint.
  • examples/smolagents_demo.py — an unmodified smolagents agent captured via one patch_litellm() call. REPLAY=1 re-runs it offline; USE_OLLAMA=1 MODEL=llama3.2 runs it against a local Ollama model.
  • examples/async_streaming_demo.py — a real AsyncOpenAI client streaming over HTTP (SSE), captured by patch_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 the with_raw_response path 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 single patch_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 with match="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 in diff; fork at step 11 and reuse 11 of 12 steps; tamper with a replayed input and see strict matching raise DivergenceError while 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 repr with a LossySerializationWarning at record time — you find out about fidelity loss while you can still fix it. (bytes keep 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/frozenset becomes a sorted list (its native order is hash-derived, and CPython randomizes string hashing per process), memory addresses in a repr are normalized away, and a non-string dict key (a frozenset, a custom object) is stringified through the same ladder rather than a raw str(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= to record(); 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, a break_at debugger 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 what match="parallel" is for. The active session propagates via a ContextVar (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= to fork()/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-inconsistent model_dump()/ construct_type() round-trip for one field, an upstream SDK bug rather than a rewind gap). A scheduled sdk-canary CI job re-runs the suite against the latest SDK releases to catch upstream movement early.
  • If a layout moves, nothing fails silently: patch_*() emits a PatchGapWarning at patch time naming each surface it could not hook (and a revival canary warns if construct_type is gone); any call through an unhooked path additionally warns at call time; failed revival serves the recorded dict with a ReviveFallbackWarning instead 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's Runner.run_streamed streams (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 a LossySerializationWarning; use .stream(...), create(stream=True), or with_raw_response there. (.create, .parse, .stream, and with_raw_response are all covered.)
  • Raw stdlib thread pools need patch_thread_pool(). A call in a concurrent.futures.ThreadPoolExecutor / threading.Thread worker starts with an empty context and is neither recorded nor replayed (a one-time ThreadlessCallWarning fires — never silent). Call rewind.patch_thread_pool() to copy the submitting thread's context into workers; then those calls record and replay offline (use match="parallel", since completion order is nondeterministic). asyncio tasks and well-behaved frameworks that copy context themselves (e.g. LangChain's own ContextThreadPoolExecutor, verified) already work without it.
  • Volatile per-run fields in the request need normalize= (or match="ordered"/ "fuzzy"). A timestamp, uuid, or per-run id stamped into a prompt (or a framework field like the OpenAI Agents SDK's random prompt_cache_key) makes the replayed request never byte-match the recording, so default strict matching raises DivergenceError. Pass normalize=fn to record()/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 with match="ordered" to serve by position. Verified end-to-end: a timestamped-prompt agent replays fully offline under strict with normalize, and the OpenAI Agents SDK under match="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(), and get_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, structured body) plus a synthetic e.response shim exposing status_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 an AttributeError that 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 to bytes on 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

agent_rewind-0.7.0.tar.gz (271.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

agent_rewind-0.7.0-py3-none-any.whl (108.4 kB view details)

Uploaded Python 3

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

Hashes for agent_rewind-0.7.0.tar.gz
Algorithm Hash digest
SHA256 675d8c008f27ea336ddde50cdafd57b34996bdce27e2309eb6eaa7a04d316140
MD5 8ae417130e72792ae8462f68b7d4a8bd
BLAKE2b-256 9d0fc3bc17dd74f21f3bc3d5d177752ef695b84014c5b5f9fe7b1b7919667521

See more details on using hashes here.

Provenance

The following attestation bundles were made for agent_rewind-0.7.0.tar.gz:

Publisher: release.yml on Abhi-2526/agent-rewind

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for agent_rewind-0.7.0-py3-none-any.whl
Algorithm Hash digest
SHA256 872dd10e7c1a6b4f0887e61e5b807b24543da6014b693f7d66f227db1f326e18
MD5 f357531593583b645f6af69dc540cbd7
BLAKE2b-256 ed32705bebc347ba13177387e58e20bc33124626efd782ed40eb13e265700a96

See more details on using hashes here.

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

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page