Record and replay LLM agent tool calls for deterministic testing
Project description
toolsnap
Zero-dependency, SDK-agnostic recorder and replayer for LLM agent. Record once, test the trajectory forever.
Why toolsnap?
LLM agents are non-deterministic at the model level, but their tool call trajectory i.e. which tools they call, in what order, with what arguments is the real observable behavior.
Existing approaches' shortcomings:
- Live APIs in every test run: Slow and expensive. Worse: varying tool responses mean the agent takes different paths each run — trajectory assertions are unreliable.
- Hand-written mocks: You write the return value before seeing what the real agent produces. If the agent never calls the tool, the mock never fails. You're testing a fiction.
- Network-level recording: Records all HTTP — including every LLM request. Fixtures balloon in size and break on any SDK update, header change, or streaming format shift.
toolsnap takes a different approach: record the real trajectory once, then replay and assert on it forever.
What toolsnap is/isn't?
It is
- A trajectory recorder and assertion library for LLM agent tool calls
- A way to pin what the agent does so prompt or code changes that alter behavior surface immediately
- Most valuable when tools call external services you cannot or should not hit in CI
It isn't
- A mock framework: you never invent return values by hand
- A network recorder: it operates at the Python function boundary, not HTTP
- A way to eliminate LLM calls: the LLM still runs in replay mode; only the tool backends are fixed
- An evaluation framework: it does not measure response quality or semantic correctness
When to use toolsnap?
Use when
- Tools that call external APIs, databases, clocks, or any service you don't want in CI
- Multi-step agents where tool call order matters
- Teams that want to catch when a prompt change silently alters agent behavior
- Any scenario where "does the agent still call the right tools?" is the key question
Don't use when
- Tools that are already pure functions with no external calls — just unit test them directly
- Testing LLM output quality or reasoning — use evals for that
- HTTP-level fidelity (exact headers, status codes) — use vcrpy or pytest-recording
Installation
pip install toolsnap
Quick Start
Step 1: Record a real agent run
# main.py — run once against live APIs
from toolsnap import snap
# auto-saves to fixtures/search.jsonl
@snap
def search(query: str) -> list[str]:
return real_search_api(query)
# auto-saves to fixtures/get_weather.jsonl
@snap
def get_weather(city: str) -> dict:
return real_weather_api(city)
agent.run("what's the weather in london and find llm docs")
# fixtures/search.jsonl and fixtures/get_weather.jsonl written
python main.py
Step 2: Assert the trajectory in tests
# test_agent.py — tool backends don't run; the LLM still runs
from toolsnap import replay
# reads from fixtures/search.jsonl
@replay
def search(query: str) -> list[str]: ...
# reads from fixtures/get_weather.jsonl
@replay
def get_weather(city: str) -> dict: ...
def test_research_agent_trajectory():
agent.run("what's the weather in london and find llm docs")
# search() and get_weather() returned their recorded responses
# assert on the agent's output or behaviour here
pytest test_agent.py # tool backends free, LLM still runs, trajectory deterministic
Three ways to test
1. @snap / @replay — single-tool, decorator style
Best when you have one tool and want the simplest possible setup.
# Record
@snap("fixtures/search.jsonl")
def search(query: str) -> list[str]:
return real_search_api(query)
# Test
@replay("fixtures/search.jsonl")
def search(query: str) -> list[str]: ...
def test_agent_uses_search():
result = agent.run("find llm docs") # search() returns recorded response
assert result is not None
2. SnapSession — multi-tool, with trajectory assertions
Best for agents that coordinate several tools. Wraps them all under one fixture and provides the full assertion API.
from toolsnap import SnapSession, contains
def test_multi_tool_agent():
with SnapSession.replay("fixtures/session.jsonl") as s:
s.wrap(search)
s.wrap(summarize)
agent.run("find and summarize llm docs")
s.assert_called("search", times=1)
s.assert_called_with("search", query=contains("llm"))
s.assert_call_order(["search", "summarize"])
s.assert_no_errors()
3. toolsnap_session pytest fixture — CLI-controlled record/replay
Best for teams. Record and replay modes are controlled from the command line — no code changes needed.
# conftest.py
pytest_plugins = ["toolsnap.pytest_plugin"]
# test_agent.py
@pytest.mark.toolsnap_fixture("fixtures/session.jsonl")
def test_agent_trajectory(toolsnap_session):
toolsnap_session.wrap(search)
toolsnap_session.wrap(summarize)
agent.run("find and summarize llm docs")
toolsnap_session.assert_called("search", times=1)
toolsnap_session.assert_call_order(["search", "summarize"])
pytest tests/ # replay — tool backends free, LLM still runs
pytest tests/ --toolsnap-record # re-record after prompt/code changes
pytest tests/ --toolsnap-strict=false # allow unexpected calls to fall through
Assertion predicates
All assertion methods accept predicate objects for structural matching:
| Predicate | Matches when |
|---|---|
contains("llm") |
value contains the substring |
matches(r"\d{4}-\d{2}") |
value matches the regex |
any_of("london", "paris") |
value is one of the given options |
gt(0) / lt(100) |
value is greater / less than threshold |
s.assert_called_with("search", query=contains("london"))
s.assert_called_with("embed", n_tokens=lt(512))
CLI: inspect and diff fixtures
After a prompt change, toolsnap diff shows exactly what shifted in the agent's trajectory:
toolsnap diff fixtures/before.jsonl fixtures/after.jsonl
# Diff: fixtures/before.jsonl → fixtures/after.jsonl
# ────────────────────────────────────────────────────
# search call 0 args unchanged result CHANGED (3 items → 2 items)
# + summarize call 0 ADDED
toolsnap show fixtures/session.jsonl # pretty-print all records
toolsnap stats fixtures/session.jsonl # call counts, avg/p95 latency, errors
toolsnap validate fixtures/session.jsonl
toolsnap list
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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file toolsnap-0.1.4.tar.gz.
File metadata
- Download URL: toolsnap-0.1.4.tar.gz
- Upload date:
- Size: 18.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a5922af34515daa09ff03df0cd4f2921fa77ad629370c6629f8384458d129525
|
|
| MD5 |
1689bb9902394abaaa514ecc4132b58c
|
|
| BLAKE2b-256 |
d02fec663ef52d630c0b1026cd2b04d1d9384eee1e84b74d1abe17317f1e2661
|
Provenance
The following attestation bundles were made for toolsnap-0.1.4.tar.gz:
Publisher:
release.yml on waitasecant/toolsnap
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
toolsnap-0.1.4.tar.gz -
Subject digest:
a5922af34515daa09ff03df0cd4f2921fa77ad629370c6629f8384458d129525 - Sigstore transparency entry: 2184797784
- Sigstore integration time:
-
Permalink:
waitasecant/toolsnap@13b45e80deb828a06e5162ab6be30c7be78d8954 -
Branch / Tag:
refs/tags/v0.1.4 - Owner: https://github.com/waitasecant
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@13b45e80deb828a06e5162ab6be30c7be78d8954 -
Trigger Event:
push
-
Statement type:
File details
Details for the file toolsnap-0.1.4-py3-none-any.whl.
File metadata
- Download URL: toolsnap-0.1.4-py3-none-any.whl
- Upload date:
- Size: 20.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
19102842d08ce2b0e86b37990c1f992a2d571182bf7199a430a8b71902ffd645
|
|
| MD5 |
0400e852c11a01b31ad1953efe7b0fe2
|
|
| BLAKE2b-256 |
39401c5d0073153de2689d8829f5e6aa9da010f224491263e4d179fd76e22ca4
|
Provenance
The following attestation bundles were made for toolsnap-0.1.4-py3-none-any.whl:
Publisher:
release.yml on waitasecant/toolsnap
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
toolsnap-0.1.4-py3-none-any.whl -
Subject digest:
19102842d08ce2b0e86b37990c1f992a2d571182bf7199a430a8b71902ffd645 - Sigstore transparency entry: 2184797895
- Sigstore integration time:
-
Permalink:
waitasecant/toolsnap@13b45e80deb828a06e5162ab6be30c7be78d8954 -
Branch / Tag:
refs/tags/v0.1.4 - Owner: https://github.com/waitasecant
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@13b45e80deb828a06e5162ab6be30c7be78d8954 -
Trigger Event:
push
-
Statement type: