Tool Replay captures, replays, forks, diffs, and promotes synchronous and sequential asynchronous AI effects into deterministic offline regression tests.
Supports explicit instrumentation of OpenAI and AsyncOpenAI Responses API calls with zero provider transport during replay, fork, verification, and generated regression tests.
- M5: explicit, non-mutating instrumentation of synchronous
OpenAI.responses.createcalls throughinstrument(...). - M5.1: lossless preservation of JSON-portable top-level OpenAI
Responsefields, while interpretedoutputand message-content unions remain closed and fail explicitly for unsupported discriminators. - M6A: native sequential
async defeffects with strict async replay, fork, promotion, and case verification. - M6B: explicit instrumentation of sequential, non-streaming
AsyncOpenAI.responses.createcalls through the same shared OpenAI codec.
OpenAI support is Responses API only and requires openai==2.20.0. Async effects
must be sequential: overlapping or nested instrumented async effects are not
supported, and there is no concurrency or asyncio.gather(...) guarantee. There
is no streaming, tools or function calls, file or binary input, Chat Completions,
Azure OpenAI, another provider adapter, or retry tracing.
Install the pinned optional OpenAI SDK:
uv sync --extra openaiBoth maintained examples are offline by default and require no API key:
uv run python examples/openai_responses_approval.py --offline-demo
uv run python examples/openai_async_responses_approval.pyThe architectural contracts are in rfcs/. Read all four RFCs before
changing runtime, matching, or storage behavior.
Python 3.11 or newer and uv are required.
uv sync
uv run ruff format --check .
uv run ruff check .
uv run pyright
uv run pytestTool Replay was created during OpenAI Build Week 2026 for the Developer Tools track.
The project's first commit was created on July 15, 2026, within the official submission period.
GPT-5.6 Sol was used for product planning, architectural research, milestone design, implementation prompts, and technical supervision. GPT-5.6 Sol and Terra were used for milestone-scoped implementation, testing, and review.
Tool Replay is an independent open-source project and is not affiliated with or endorsed by OpenAI.
Tool Replay is available under the MIT License.
Recorded TimeoutError failures are persisted and reproduced during strict replay without executing the live effect body. M1 is synchronous and supports portable JSON values; allowlisted built-in exceptions are reconstructed, custom exceptions become ReplayedEffectError, and tracebacks are not captured.
- record: run live effect bodies and persist their inputs/outputs.
- replay: substitute recorded outputs/failures for the exact recorded identity; never run live effect bodies.
- fork: like replay, but against a new branch trace, with selected recorded outputs replaced by patched values before your application code runs; still never runs live effect bodies.
import toolreplay
patch = toolreplay.replace_output(
effect="crm.customer.get",
inputs={"customer_id": 42},
occurrence=0,
value={"id": 42, "active": False},
)
with toolreplay.fork("source.trp", "branch.trp", patches=[patch]):
decision = decide(42) # observes the patched customer, not the recorded oneEvery patch is resolved against the source trace's exact recorded
(namespace, name, input_fingerprint, occurrence) identity before any
application code runs. After the with block exits normally, Tool Replay
verifies every configured patch was applied and every source operation was
observed; an application exception always propagates unmodified instead of
being masked by that check. See examples/fork_replace_output.py.
M2 limitations: synchronous only, strict tail only (tail="strict"), output
replacement only (no replace_input/replace_error/drop/force_live),
exact effect identity is required for both replay and patch targeting, only
portable JSON values are supported, and there is no live tail, no structural
diff/first-divergence analysis, and no provider adapters.
record:
capture what happened
replay:
reproduce what happened
fork:
replace selected recorded outputs
diff:
show the first observable difference between traces
toolreplay.diff(source_trace, target_trace) is a read-only structural
comparison that answers one narrow question: what is the first observable
behavioral difference between two recorded executions? It does not claim
root cause, causal proof, internal program-state divergence, semantic
equivalence, or intent.
import toolreplay
report = toolreplay.diff("source.trp", "branch.trp")
if report.first_divergence is not None:
print(report.first_divergence)
print(report.to_dict()) # JSON-compatible, deterministicOperations are aligned in two conservative phases: exact alignment on the
full (namespace, name, input_fingerprint, occurrence) identity, then, for
whatever remains unmatched, structural alignment on (namespace, name, occurrence) -- but only when exactly one candidate exists on each side and
the pairing does not violate ordering established by exact-alignment
anchors. Ambiguous or otherwise unsafe pairings are left unmatched rather
than guessed; a trace containing duplicate complete identities fails
explicitly instead of aligning arbitrarily. Detected difference types
include input_changed, output_changed, status_changed,
error_changed, operation_missing, operation_added, and
order_changed. When a target operation's persisted v3 branch lineage
(origin="patched", patch_id, source_logical_operation_id) proves it
was produced by a replace_output patch, the report exposes a narrow
attribution -- never a claim that the patch caused downstream differences.
Command line:
uv run toolreplay diff source.trp branch.trp
uv run toolreplay diff source.trp branch.trp --format jsonText output is deterministic, unambiguous, and suitable for snapshots or
CI logs; --format json prints only JSON (with format_version) to
stdout. Exit code is 0 whether or not differences were found, and
non-zero only for invalid arguments or a comparison failure (there is no
--fail-on-difference in this milestone). See examples/diff_traces.py.
M3 limitations: synchronous, local trace model only; only captured operations are compared (no pure internal-state inspection); no root-cause analysis or causal propagation; structural alignment is conservative and ambiguous operations are left unresolved rather than guessed; no semantic or embedding-based text comparison; no provider-specific interpretation; and no web viewer.
record → replay → fork → diff → promote
toolreplay.promote(trace, output, *, name, runner, expected) turns an
independently-replayable fixed trace (typically a branch produced by
fork()) into a portable, self-contained pytest case:
import toolreplay
case = toolreplay.promote(
"branch.trp",
"cases/crm_customer_inactive_rejects",
name="crm_customer_inactive_rejects",
runner="myapp.cases:crm_customer_inactive_rejects", # module:callable
expected={"decision": "rejected"},
)
print(case.directory, case.trace_sha256)The runner is a synchronous, no-argument, importable callable (a plain
module-level function, or module.path:Class.method) that exercises your
application code; by the time it runs, promote()/verify_case() has
already opened a strict-replay session against the case's trace, so any
@effect-decorated function the runner calls is transparently substituted
-- the runner itself never opens a session.
promote() stages the case in a sibling temporary directory, verifies it
end to end (integrity, runner resolution, strict replay, completeness, and
the behavioral expectation) via verify_case(), and only then publishes it
atomically to output; any failure leaves no partial output directory
behind. The destination must not already exist -- there is no
overwrite/force/merge.
A promoted case directory contains exactly three assets:
-
case.json-- a deterministic, canonical-JSON manifest (format,format_version=1,name,trace(content-addressedpath,digest,size_bytes),runner,replay.mode="strict",expectation, andscope). No timestamps, random ids, machine paths, source/branch trace paths, patch data, diff reports, git hashes, or environment dumps. -
trace-sha256-<64 lowercase hex>.trp-- a real byte copy (never a link) of the trace, named after its own SHA-256 digest. -
test_case.py-- a thin, fully static generated pytest module:from pathlib import Path import toolreplay def test_toolreplay_case() -> None: toolreplay.verify_case(Path(__file__).resolve().parent)
toolreplay.verify_case(case_directory) re-runs a promoted case and is
what the generated test calls. It validates the manifest and trace
integrity -- including the trace's SHA-256 digest and byte size --
before it ever imports or executes the runner's module; only after
integrity passes does it resolve and import the runner, strictly replay it
(running zero live effect bodies), confirm every recorded operation was
consumed, and finally compare the runner's return value against the case's
expected value. It opens the trace read-only and never writes or modifies
any case asset.
verify_case() raises one of four case-specific errors, each meaning
something different:
CaseIntegrityError-- the manifest or trace asset itself is invalid (missing/malformed/wrong-version manifest, bad digest, an absolute/escaping/symlinked trace path, a digest or size mismatch, or a malformed/missing/async/argument-requiring runner target). Always raised before any application code is imported or executed.ReplayContractError-- strict replay of the runner failed: a recorded effect could not be found or was ambiguous, or the case trace contained a recorded operation the runner never consumed. Wraps the originalReplayMiss/AmbiguousReplayMatchas__cause__when there is one.BehavioralRegressionError-- strict replay completed successfully, every required operation was consumed, and the runner's effect bodies ran zero times, but the runner's return value differs from the case's expected value. Its very existence proves replay already succeeded. Carriesexpected,actual, and deterministic JSON-pathdifferences.ApplicationExecutionError-- the runner raised an exception that is not itself a replay-contract failure, or returned a value that is not portable JSON. Preserves the original exception as__cause__.KeyboardInterrupt/SystemExitare never wrapped.
Command line:
uv run toolreplay promote branch.trp \
--output cases/crm_customer_inactive_rejects \
--name crm_customer_inactive_rejects \
--runner myapp.cases:crm_customer_inactive_rejects \
--expect-json '{"decision": "rejected"}'
# or: --expect-file expected.jsonOn success it prints the case directory, name, trace path, trace SHA-256,
generated pytest path, and a confirmation line; exit code 0. It exits
non-zero for invalid arguments, invalid/missing expectation JSON, an
existing destination, or any promotion failure, with the error on stderr
and no partial case directory left behind. See
examples/promote_regression_case.py.
M4 limitations: the runner must be a synchronous, no-argument, importable
callable (no async, no required arguments); the only supported expectation
is exact canonical-JSON equality of the return value (return_equals) --
no expected-exception assertions, matchers, or DSL; a case only covers the
instrumented effects captured in its trace -- it makes no claim about
uninstrumented side effects, external system state, non-deterministic
control flow, databases, subprocesses, or concurrency/timing; there is no
sandboxing, code signing, packaging, pytest plugin/collector, or recursive
toolreplay verify <dir> discovery command.
M5 supports exactly openai==2.20.0: synchronous, non-streaming
responses.create only. The core package does not import OpenAI; importing or
using the adapter without this optional dependency raises
OpenAIAdapterDependencyError.
from openai import OpenAI
from toolreplay.adapters.openai import instrument
client = instrument(OpenAI())
response = client.responses.create(
model="...",
input="...",
reasoning={"effort": "minimal"},
max_output_tokens=512,
)instrument() is an explicit, non-mutating wrapper. Use
client.original_client as the escape hatch to the unwrapped client. stream
may be omitted or explicitly False. The four supported semantic request
fields are model, a UTF-8 string input,
reasoning={"effort": "minimal"|"low"|"medium"|"high"}, and a positive
max_output_tokens.
Successful calls and supported provider errors are recorded as portable
envelopes. Strict replay, strict fork, promoted-case verification, and generated
regression tests reconstruct the exact public Response from those envelopes
with zero provider transport. M5.1 preserves every JSON-portable top-level field
from the SDK's public serialization without silently dropping data. The
interpreted semantic unions remain closed: output supports only reasoning and
message items, and message content is limited to output_text and refusal;
unsupported discriminators fail explicitly. The application input intentionally
sent to the model is recorded; credentials, authorization and custom headers,
transport internals, and raw HTTP objects are excluded. The
prompt_cache_retention: "in_memory" -> "in-memory" compatibility shim is
specific to SDK 2.20.0.
The maintained synchronous command is listed in OpenAI setup and offline
examples above; its --offline-demo workflow requires no API key.
It demonstrates recording approve, strict replay with zero provider calls,
a strict fork that replaces the complete portable response envelope with
manual_review, and a first observable divergence of
openai.responses.create. Promotion is covered by deterministic tests rather
than the default demo. Its API is:
toolreplay.promote(
branch,
"tests/cases/openai-approval",
name="openai-approval",
runner="your_module:run_case",
expected="manual_review",
)To make one live supported call, configure credentials locally and set a model; credentials are never printed. This credential-gated path is not part of default CI and does not claim deterministic provider wording:
$env:OPENAI_MODEL="..."
uv run python examples/openai_responses_approval.py --live-recordThe shared current OpenAI and async limitations are listed prominently above. M5's invocation shell is synchronous; M6B adds the asynchronous shell without duplicating the request, response, error, redaction, or compatibility codec.
The maintained visual demo presents an intentionally buggy refund decision: a
captured approve model result produces approved, even though refunds above
$1,000 require manual review. Its default workflow is fully offline and uses
the actual Tool Replay record, strict replay, fork, diff, promotion, and case
verification APIs--not JavaScript simulations or a provider call.
After installing the OpenAI extra as shown above, run:
uv run python -m examples.incident_replay_demo.serverOpen http://127.0.0.1:8765. Select Replay incident, Apply correction, Compare executions, then Promote to test. Each server run owns a temporary local scenario; use Reset demo (or restart the server) to reset it. Replay, fork, branch replay, and case verification use an exploding local transport and display zero provider calls. The fork is a controlled alternate execution that verifies the desired behavior before creating an offline pytest case; its First observable divergence is not a claim of automatic root cause discovery.
This presentation demo has the same M5 limitations: it is synchronous, non-streaming Responses-only, supports no tools/function calls, Azure, files, other providers, or partial patches. It requires no API key and never exposes credentials, trace paths, HTTP objects, or transport configuration.
instrument(AsyncOpenAI(...)) explicitly wraps only non-streaming
responses.create using the same closed request and portable response/error
contract as the synchronous adapter. It accepts the exact request surface
described above, including stream omitted or explicitly False. Strict async
replay and fork do not create or await a provider coroutine and make zero
provider transports. Use promote_async() and verify_case_async() for
self-contained offline async cases; their generated synchronous pytest owns one
asyncio.run() loop.
The maintained asynchronous command is listed in OpenAI setup and offline
examples above; its default workflow requires no API key. The optional
credential-gated live capture uses AsyncOpenAI with max_retries=0 and a
counting httpx.AsyncClient; it is never run by default and does not print
credentials. The shared current limitations are listed prominently above.