Metadata-Version: 2.4
Name: agent-rewind
Version: 0.7.0
Summary: Record, replay, and time-travel-debug LLM agent runs.
Project-URL: Homepage, https://github.com/Abhi-2526/agent-rewind
Author-email: Abhinav Sood <abhinavsood401@gmail.com>
License: MIT
License-File: LICENSE
Keywords: agents,debugging,llm,replay,testing,tracing
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Debuggers
Classifier: Topic :: Software Development :: Testing
Requires-Python: >=3.10
Provides-Extra: anthropic
Requires-Dist: anthropic>=0.40; extra == 'anthropic'
Provides-Extra: cohere
Requires-Dist: cohere>=6.0; extra == 'cohere'
Provides-Extra: dev
Requires-Dist: httpx; extra == 'dev'
Requires-Dist: hypothesis>=6; extra == 'dev'
Requires-Dist: mypy; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff; extra == 'dev'
Provides-Extra: e2e
Requires-Dist: langchain-fireworks; extra == 'e2e'
Requires-Dist: langchain-groq; extra == 'e2e'
Requires-Dist: langchain-openai; extra == 'e2e'
Requires-Dist: langgraph; extra == 'e2e'
Requires-Dist: openai-agents; extra == 'e2e'
Requires-Dist: pydantic-ai; extra == 'e2e'
Provides-Extra: embeddings
Requires-Dist: sentence-transformers>=2.2; extra == 'embeddings'
Provides-Extra: fireworks
Requires-Dist: fireworks-ai>=1.2.0; extra == 'fireworks'
Provides-Extra: gemini
Requires-Dist: google-genai>=1.50; extra == 'gemini'
Provides-Extra: groq
Requires-Dist: groq>=0.37; extra == 'groq'
Provides-Extra: litellm
Requires-Dist: litellm>=1.90; extra == 'litellm'
Provides-Extra: mcp
Requires-Dist: mcp>=1.0; extra == 'mcp'
Provides-Extra: mistral
Requires-Dist: mistralai>=1.0; extra == 'mistral'
Provides-Extra: openai
Requires-Dist: openai>=1.40; extra == 'openai'
Provides-Extra: together
Requires-Dist: together>=2.0; extra == 'together'
Description-Content-Type: text/markdown

# 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

```python
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.):

```python
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 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](#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

```python
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:

```python
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:

```python
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:

```python
# 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

```python
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

```python
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

```python
@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
