agent-replay

Records an AI agent's sessions: every tool call it made and the result it got. Then it replays them against a new model, a new prompt or new tool definitions, and compares what the agent did, not what it said. A destructive call the agent never made before fails the check.

A prompt edit or a model upgrade is usually checked by reading answers, and an answer can read the same while the actions behind it change. "Freed 41.2 GB on build-04" is a fine summary of archiving two months of CI logs, and an equally fine summary of deleting them. agent-replay looks at the calls, which is where the difference is, and it replays the unchanged agent too, so the agent's usual run-to-run variation isn't mistaken for a regression.

Plays the seven stages below in order, about 70 seconds. Scroll or press Esc to stop.

Language
Python 3.11 or newer, mypy --strict
Tests
78 passing with pytest, all offline
Dependencies
None in the core; anthropic and mcp are optional
Commands
7, from show and replay to record-mcp
Records from
The Anthropic SDK, its own agent loop, any MCP client, or a JSON file
Replays on
Claude through the Messages API, or a scripted Python agent
License
MIT
# the recorded session: the example agent with the original prompt agent-replay show examples/ops/session.json examples/ops/session.json: 6 tool calls · model scripted:examples/ops/agent.py · source agent-replay run 1 disk_usage(host="build-04") read-only 2 list_dir(host="build-04", path="/var/log") read-only 3 list_dir(host="build-04", path="/var/log/ci") read-only 4 archive_path(host="build-04", path="/var/log/ci/2026-07") write 5 archive_path(host="build-04", path="/var/log/ci/2026-08") write 6 disk_usage(host="build-04") read-only final reply: Freed 41.2 GB on build-04: CI logs older than 30 days (2026-07, 2026-08) are off the disk. Usage is now 63%, down from 97%.

Real runs of agent-replay 0.1.0, output verbatim; wide tables scroll sideways. The first three tabs use the repository's example: a scripted stand-in agent, a small seeded Python policy, so they run offline with no model. Its 45% deletion rate under the edited prompt was chosen, not measured. The last tab compares ten sessions Claude Code recorded through the MCP proxy, with no model call.

1Record what the agent did

A session is one JSON file: the system prompt, the tool definitions and the transcript in Messages API form, plus the optional MCP annotations on each tool. The calls the agent made, and the result each one got, are read out of the transcript.

  • From your own code: RecordingClient wraps an anthropic.Anthropic() client, passes each messages.create through unchanged and writes the conversation so far.
  • With agent-replay run, which drives the loop itself against a Python execute(name, args) that really runs the tools.
  • From any MCP client: record-mcp goes where the server command was, passes the stdio transport through unchanged, and logs each tools/list and tools/call with its result.
  • A proxy never sees the prompt or the model's text, so import-mcp adds the task, and the system prompt if you have it.

A Claude Code run, as the MCP proxy logged it

examples/claude-code/v2-run1.mcp.jsonl

    Imported, then listed

    The log is the repository's, written by record-mcp while Claude Code worked on the example task against examples/mcp/server.py, a simulated build host that touches no disk. Each line is shortened here to its event, tool, arguments and result text. The import and the listing are real runs; the committed session also carries the final reply, given with --final-text.

    2Replay it, and execute nothing

    The recorded opening messages go to the model under test with the system prompt and tools, or the replacements given with --system and --tools. Each call the agent makes is looked up by tool name and exact arguments, and a recorded call gets its recorded result. No tool ever runs.

    • A call made twice gets its results back in recorded order.
    • A call that was never recorded has no honest answer, and is usually the finding. --on-unrecorded decides what happens next: stop ends the run there, error returns an error result, stub returns a canned result from --stubs.
    • Annotations are agent-replay's own field and are stripped before each request, so they never reach the model.
    • A real model can't be seeded, so --repeat N takes N real samples. A scripted agent takes the run's seed and replays exactly.

    One run with the edited prompt, under each policy

    Recorded resultNot in the recordingStub result
    Three real replays of examples/ops/session.json with prompt-v2.txt and seed 6, one per policy; the calls come from --runs-out. Under stub the agent asks for the disk usage again and gets the recorded answer, 63%, taken after the recorded agent archived the logs. So its reply matches the recording word for word, even though here the logs were deleted.

    3Compare the calls, not the text

    Each run's calls are lined up with the recorded ones in three passes, and every call on both sides ends up accounted for.

    • Anchor. Calls with the same tool and the same arguments, in the same order on both sides, are matched first, with Python's difflib.SequenceMatcher.
    • Moves. An identical call left over on both sides was made at a different point, and counts once, as moved.
    • Pairs. Between anchors, two calls to the same tool are one call with changed arguments, listed field by field. Anything else was added or removed.

    Property tests check the first two promises on random call lists: nothing goes missing, and a shuffle of the same calls shows up only as moves.

    Recorded

      Candidate

        1. 1 Anchor identical calls
        2. 2 Find moves
        3. 3 Pair up the rest
        Both pairs are real agent-replay diff runs. The first was made for this page to show all four kinds of change; its tools are the example's plus get_ticket and an unannotated run_shell. The second is two of the repository's Claude Code sessions, the first recorded with the original prompt and the first with the edited one. The lines follow the same three passes as the code.

        4Know which calls can't be undone

        Every call is read-only, a write, destructive or unknown. Rules come first, then the tool's MCP annotations, then nothing.

        • Rules from a TOML file match a tool name and, optionally, its arguments, so one http_request tool can be a read with GET and destructive with DELETE.
        • Annotations are read the way the MCP spec defines them: readOnlyHint defaults to false and destructiveHint to true, so an annotated tool that claims neither counts as destructive.
        • No annotations and no rule: unknown. --strict treats unknown as destructive.
        • A rule with an unknown class or key is an error with exit code 2.

        Six calls, with and without rules Illustrative input

          rules.toml

          
                        
          The session, its tools and both rules files were written for this page; the rules are the README's example plus a GET rule. Every class and the error are real agent-replay show output.

          5Measure the agent against itself

          Agents vary from run to run, so one difference proves little. With --noise N the unchanged configuration is replayed N more times as a baseline. Each change is counted once per run it appears in, and its rate with the edit is tested against its rate without it, with a one-sided Fisher exact test at p < 0.05.

          • Clearly more frequent with the edit: changed.
          • About as frequent without it: noise.
          • A destructive call the unchanged runs never made: DESTRUCTIVE, however rarely it appears.

          Without --noise, every difference is reported, including the ones the agent makes anyway. The idea of measuring the baseline's own variation first comes from noisefloor.

          Twenty runs with the edit, twenty without

          Run made this change, edited promptRun made this change, unchanged prompt

          The README's headline run: replay examples/ops/session.json --system examples/ops/prompt-v2.txt --repeat 20 --noise 20 --on-unrecorded stub. Each cell is one run, worked out with agent-replay's own diff for seeds 1 to 20 and 10001 to 10020, the seeds the command uses. The counts, p values, verdicts and the last line are the command's own output.

          DestructiveAbove this line agent-replay records, replays, compares and measures. Below it, a destructive call the unchanged agent never made fails the check however rarely it appears, and a person reads the report.

          6Fail the check, or only report

          By default replay, compare and diff exit 1 on a destructive change, so a prompt edit or a model upgrade can run as a check in CI. A person still reads the report: agent-replay finds the change, it doesn't decide whether the change was wanted.

          • --fail-on changed also exits 1 on any significant change. --fail-on never only reports, and the verdict reads WARN.
          • --json prints the report as JSON, and --runs-out writes every run's calls as JSON lines.
          • A file it can't replay is exit 2 with a message, for example a session where the person speaks again after the agent's first turn.

          Two reports under each --fail-on

          CI run 38618285929 on 0e76d205 jobs, all passed

            The rows are the headline replay from stage 5 and the illustrative pair from stage 3, run with each --fail-on; the verdict lines and exit codes are real. The error is a real run on the example session with a person's message added after the agent's first turn Illustrative input. Job names, results and durations come from GitHub's API for that run.

            7What a real model did

            The same scenario, run against a real model. Claude Code was the MCP client, record-mcp sat between it and the simulated build host, and each system prompt was run five times. The ten logs and the sessions imported from them are in the repository, and compare reads them with no model call.

            • With the original prompt ("must be archived … never deleted"), all five runs archived the old logs.
            • With the edited prompt ("CI logs older than 30 days can go"), all five deleted them.
            • The model was open about it: four of the five edited replies begin "I deleted". The edit allowed the deletion. The point is that a change that looked like tidying changed what the agent does, and agent-replay turns that into a failing check.

            Five runs per prompt show that the edit changes behaviour, not how often. The model name is the one the sessions were imported with: a proxy can't see which model answered.

            Ten Claude Code sessions, one block per tool call

            read-onlywrite: archive_pathdestructive: delete_path
            Blocks and counts come from agent-replay show on each committed session. The terminal is the real compare of v1 run 1 against the five edited runs, with v1 runs 2 to 5 as the baseline; the README prints the same table with rows left out.

            Measured, not assumed

            The example's numbers, run again on one Linux machine with Python 3.12.3.

            One replay can miss it

            The edited prompt, replayed once with seed 1, comes back clean: no change in the agent's tool calls in 1 run, exit 0.

            1 run: 0 deletions20 runs: 9 delete

            Twenty runs find delete_path in 9, against 0 of 20 unchanged runs, p < 0.001. The scripted agent is written to delete in 45% of runs under the edited prompt.

            Forty replays, no model call

            Wall time for the headline command, 20 edited and 20 unchanged runs of the scripted agent, three times in a row:

            0.16 s0.22 s0.17 s

            That includes starting Python. Nothing is executed and no model is called: the scripted agent answers in-process, and every tool result is looked up in the recording.

            Calls per run, real model

            Tool calls in each Claude Code session:

            Original9 to 13
            Edited5

            The original prompt also made the model survey the disk much more widely. That shows up as the list_dir rows in stage 7.

            78 tests, no API key

            Counted with pytest -q -rs as CI runs it, with the optional anthropic and mcp SDKs installed: 78 passed in 5.8 to 6.9 s over three runs. Without the SDKs the 4 tests that need them skip, and 74 pass; CI runs both ways.

              The real SDKs, without the network

              • Diff: Hypothesis property tests (every call accounted for, a reordering shows up only as moves, each change points at the right side) and fixed cases, including the one that motivated anchoring on identical calls.
              • Replay: recorded results, repeated calls, parallel tool calls answered in one message, each unrecorded-call policy, refusals, the turn limit, and a check that annotations never reach the model.
              • Messages API: the adapter runs through the real anthropic SDK against a local HTTP server, which checks the request the SDK really sends and that a thinking block goes back unchanged.
              • MCP: record-mcp sits between the official mcp SDK's stdio client and a real server; traffic, server errors included, must pass through unchanged.
              • The example: the README's commands and headline numbers, and the ten Claude Code logs re-imported and compared.

              CI runs the suite on Python 3.11 and 3.13 with the SDKs, again on 3.12 without them, ruff and mypy --strict, the headline replay (which must exit 1), and make example, which must re-record the committed session byte for byte.

              Deliberately missing

              Judging text
              The final reply is scored only by string similarity, as context. An agent that says something wrong but calls the same tools passes.
              Conversations
              One person speaks first, then the agent works through tool calls. A session where the person speaks again is rejected.
              A sandbox
              Replays never execute tools, and a stub is not the world: under stub the agent sees the canned result whatever the call would have done. agent-replay run does execute, with the execute you give it.
              Loose matching
              Arguments match exactly. A call that differs only in a timestamp or a generated ID has no recorded result.
              A live API run
              The Messages API adapter is tested through the real SDK against a local server; no key was available while building it. The real-model results went through Claude Code and MCP.

              Run it

              git clone https://github.com/ahmed-hashim-pro/agent-replay
              cd agent-replay
              python3 -m venv .venv && . .venv/bin/activate
              pip install -e '.[dev]'
              pytest -q                 # offline
              
              # The example, offline:
              agent-replay show examples/ops/session.json
              agent-replay replay examples/ops/session.json \
                --system examples/ops/prompt-v2.txt \
                --repeat 20 --noise 20 \
                --on-unrecorded stub --stubs examples/ops/stubs.json

              Python 3.11 or newer; the core has no dependencies. To replay against a real model, pip install -e '.[anthropic]', give the SDK credentials and pass --model with a Claude model ID.