pytest-style behavioral contracts for LLM agent tool-call sequences
Project description
AgentSpec
Behavioral contracts for LLM agents. Like pytest, but for agent behavior.
pip install llm-agentspec
What is this?
If you're building AI agents that call tools (search the web, read files, query databases, send emails), you need a way to make sure the agent is doing the right things in the right order.
AgentSpec lets you write simple rules like:
- "The agent must search before it summarizes"
- "The agent must never call the delete function"
- "The agent can search at most 3 times (to control costs)"
And then it checks whether your agent actually followed those rules. If it didn't, you get a clear pass/fail result that works in your test suite.
No AI needed to run the checks. No API keys. No cloud platform. Just pure Python that runs in microseconds.
Why does this exist?
I kept seeing the same problem in AI engineering communities in 2026:
"My agent summarized before it even searched. The output looked fine, but the process was completely wrong."
"We added a tool that can delete user data. How do I make sure the agent never calls it?"
"We're paying $0.03 per search call. The agent searched 47 times on one query. We need a hard limit."
Tools like DeepEval and RAGAS can tell you if the agent's output is good (not hallucinated, relevant, coherent). But they can't tell you if the agent behaved correctly -- did it call the right tools, in the right order, the right number of times?
That's what AgentSpec does. It checks the behavior, not the output.
Anthropic's own engineering blog "Demystifying evals for AI agents" makes this exact point: you need to verify that agents followed the right process, not just that they produced a plausible answer.
Quick start
1. Install
pip install llm-agentspec
That's it. Zero dependencies. Takes about 1 second.
2. Write your first contract
from agentspec import ContractSet, AgentSession, ToolCall
# Say you have a research agent that should:
# 1. Search the web first
# 2. Then summarize what it found
# 3. Never delete any files
# 4. Not search more than 5 times (cost control)
spec = ContractSet("research_agent")
spec.must_call("search") # Must search at least once
spec.must_call_before("search", "summarize") # Search BEFORE summarizing
spec.must_not_call("delete_file") # Never call this
spec.must_call_at_most("search", n=5) # Cost control
3. Check your agent's behavior
# After your agent runs, you have a record of what tools it called.
# Wrap that in an AgentSession:
session = AgentSession(tool_calls=[
ToolCall(name="search", args={"query": "latest AI research"}),
ToolCall(name="search", args={"query": "transformer architectures"}),
ToolCall(name="summarize", args={"format": "bullets"}),
])
# Check all contracts at once
report = spec.check(session)
print(report)
Output:
AgentSpec — Contract Report
Spec: 'research_agent'
--------------------------------------------------
[✓] must_call(search): 'search' was called
[✓] must_call_before(search, summarize): 'search' (step 0) correctly precedes 'summarize' (step 2)
[✓] must_not_call(delete_file): 'delete_file' was correctly not called
[✓] must_call_at_most(search, n=5): 'search' called 2 time(s) (limit: 5)
--------------------------------------------------
4 passed, 0 failed
Overall: PASS
4. Use it in pytest
# test_my_agent.py
def test_research_agent_follows_rules():
spec = ContractSet("research_agent")
spec.must_call("search")
spec.must_call_before("search", "summarize")
spec.must_not_call("delete_file")
session = run_my_agent("Summarize recent AI news") # your agent function
report = spec.check(session)
report.assert_all_pass() # raises AssertionError if anything failed
Run it:
pytest test_my_agent.py -v
If the agent misbehaves, you get a clear error:
AssertionError: AgentSpec 'research_agent' failed 2 contract(s):
- 'search' was never called
- 'delete_file' was called 1 time(s) but must never be called
Real-world examples
Example 1: Customer support agent
A company builds a support agent that handles refund requests. The agent has access to lookup_order, check_refund_policy, process_refund, respond_to_customer, and delete_account.
spec = ContractSet("support_agent")
spec.must_call("lookup_order") # Always check the order first
spec.must_call("check_refund_policy") # Always check policy
spec.must_call_before("check_refund_policy", "process_refund") # Policy check BEFORE processing
spec.must_call_before("lookup_order", "respond_to_customer") # Look up order BEFORE responding
spec.must_not_call("delete_account") # Never delete accounts
spec.must_call_at_most("respond_to_customer", n=1) # Only respond once
Why this matters: Without these contracts, the agent might approve a $10,000 refund without checking the policy, or accidentally call delete_account because the LLM hallucinated.
Example 2: Code review agent
An engineering team builds an agent that reviews pull requests.
spec = ContractSet("code_review_agent")
spec.must_call("fetch_diff") # Must actually read the code
spec.must_call_before("fetch_diff", "post_review") # Read code BEFORE reviewing
spec.must_not_call("merge_pr") # Review agent should NEVER merge
spec.must_call_at_most("call_llm", n=10) # Budget: max 10 LLM calls per PR
spec.must_call_in_sequence("fetch_diff", "analyze", "post_review")
Example 3: Cost control
Your agent calls an expensive API at $0.03 per call. You need a hard limit.
spec = ContractSet("my_agent")
spec.must_call_at_most("expensive_api", n=3) # Max $0.09 per run
session = run_my_agent(user_query)
report = spec.check(session)
if report.overall.value == "FAIL":
alert_engineering_team(report)
All available contracts
| Contract | What it checks | Example |
|---|---|---|
must_call(tool) |
Tool was called at least once | spec.must_call("search") |
must_not_call(tool) |
Tool was never called | spec.must_not_call("delete_file") |
must_call_before(a, b) |
Tool a was called before tool b |
spec.must_call_before("search", "summarize") |
must_call_at_most(tool, n) |
Tool was called no more than n times | spec.must_call_at_most("search", n=5) |
must_call_at_least(tool, n) |
Tool was called at least n times | spec.must_call_at_least("search", n=2) |
must_not_call_after(tool, trigger) |
After trigger is called, tool is never called again |
spec.must_not_call_after("search", "write_report") |
must_call_in_sequence(*tools) |
All tools appear in this exact order | spec.must_call_in_sequence("search", "analyze", "report") |
must_call_exactly(tool, n) |
Tool was called exactly n times | spec.must_call_exactly("search", n=2) |
must_call_with_arg(tool, key, val) |
At least one call includes key=val in args |
spec.must_call_with_arg("search", "source", "verified") |
All contracts can be chained:
spec = (ContractSet("my_agent")
.must_call("search")
.must_call_before("search", "summarize")
.must_not_call("delete")
.must_call_at_most("search", n=5))
How it works under the hood
Your agent runs
|
v
Tool calls are recorded as a list of ToolCall objects
|
v
You wrap them in an AgentSession
|
v
ContractSet.check(session) runs every contract against the session
(pure Python -- no LLM calls, no network, no API keys)
|
v
Returns a ContractReport with pass/fail for each contract
|
v
report.assert_all_pass() -- use this in pytest
The key thing: every check is deterministic. If the tool was called, it was called. There's no LLM judging anything, no probability scores, no flakiness. Your CI pipeline gets a reliable green/red signal.
How is this different from DeepEval, LangSmith, etc?
Honest comparison. I'm not going to pretend AgentSpec replaces these tools. They do different things.
| AgentSpec | DeepEval | LangSmith | Promptfoo | |
|---|---|---|---|---|
| What it does | Checks agent behavior (tool calls) | Checks output quality (hallucination, relevancy) | Traces + evaluates agent runs | General LLM evaluation |
| Needs an LLM to run? | No, never | Yes (LLM-as-judge) | Sometimes | Yes |
| Needs an API key? | No | Yes | Yes | Yes |
| Speed | 9 microseconds | ~100+ milliseconds | ~200+ milliseconds | ~500+ milliseconds |
| Dependencies | Zero | 26+ packages | Platform | Node.js + many |
| Install size | 15 KB | ~50+ MB | Platform | Heavy |
| Works offline | Yes | No | No | No |
| Can score output quality | No | Yes | Yes | Yes |
| Has dashboards | No | Yes | Yes | Yes |
When to use AgentSpec: You want fast, deterministic pass/fail checks on what tools your agent called. You want this in CI. You don't want to pay for LLM calls just to run tests.
When to use DeepEval: You want to know if the agent's output is good (not hallucinated, relevant, coherent). You want a cloud dashboard.
Use both together. AgentSpec checks "did the agent do the right thing?" DeepEval checks "was the output good?" Both questions matter.
Performance
These aren't made-up numbers. Measured with pytest-benchmark, the industry standard for Python benchmarking. Auto-calibrated with 43,000 to 132,000 iterations per test.
| Session size | Median time | Operations per second |
|---|---|---|
| 5 tool calls | 3.38 us | 293,275 |
| 10 tool calls | 3.67 us | 270,259 |
| 25 tool calls | 4.54 us | 215,730 |
| 50 tool calls | 6.04 us | 162,579 |
| 100 tool calls | 9.04 us | 108,958 |
That's microseconds, not milliseconds. 7 contracts checked on a 100-call session in 9 microseconds.
Stress tested
| Test | Result |
|---|---|
| 100,000 sessions in a row | 0.34 seconds (296K ops/sec) |
| 5,000 tool calls in one session | 303 microseconds |
| 100 contracts on one session | 179 microseconds |
| 20 threads running simultaneously | 185K ops/sec, zero errors |
| 10,000 create-and-discard cycles | No memory leaks |
Run the benchmarks yourself:
pip install pytest-benchmark
pytest benchmarks/test_benchmark.py --benchmark-only
pytest benchmarks/test_stress.py -v -s
Building an AgentSession from your agent framework
AgentSpec doesn't care which framework you use. You just need to convert your agent's tool calls into a list of ToolCall objects.
From Anthropic Claude API (tool_use)
Use the built-in adapter — no manual block iteration:
from anthropic import Anthropic
from agentspec import ContractSet
from agentspec.adapters.anthropic import from_anthropic_response
client = Anthropic()
resp = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
tools=[...],
messages=[{"role": "user", "content": "find docs about X"}],
)
session = from_anthropic_response(resp)
spec = (ContractSet("docs_agent")
.must_call("search")
.must_not_call("delete_file")
.must_call_at_most("search", n=3))
spec.check(session).assert_all_pass()
For multi-turn tool-use loops, use from_anthropic_messages(messages) to
capture every tool call across every assistant turn. The adapter works on
both live SDK objects and recorded JSON fixtures, so you can commit real
sessions to your repo and replay them in CI without an API key. See
examples/case_study_research_agent/
for a full end-to-end demo — a good session passes all six contracts, a
runaway session trips at-most + not-call and fails the CI run.
From LangChain / LangGraph
# If you have a list of AgentAction objects
tool_calls = [
ToolCall(name=action.tool, args=action.tool_input)
for action in agent_actions
]
session = AgentSession(tool_calls=tool_calls)
From OpenAI function calling
# From ChatCompletion response with tool_calls
tool_calls = [
ToolCall(name=tc.function.name, args=json.loads(tc.function.arguments))
for tc in response.choices[0].message.tool_calls
]
session = AgentSession(tool_calls=tool_calls)
Manually in tests
session = AgentSession(tool_calls=[
ToolCall(name="search", args={"query": "test"}),
ToolCall(name="summarize", args={}),
])
Running the demo
git clone https://github.com/rakshithmuda22/agentspec.git
cd agentspec
pip install -e ".[dev]"
python demo/demo.py
The demo runs without any API key. It shows a well-behaved agent that passes all contracts and a broken agent that fails multiple contracts, side by side.
Tests
pytest tests/ -v
# 73 passed in 0.06s
All tests run in mock mode. No API keys needed. No network calls.
Project structure
agentspec/
agentspec/ # The pip-installable package
__init__.py # Public API: ContractSet, AgentSession, ToolCall, etc.
contracts.py # Contract engine (all 7 contract types)
recorder.py # Helper to build AgentSession from different sources
reporter.py # Formatted report output (uses Python logging)
config.py # Configuration (reads from environment variables)
pytest_plugin.py # pytest fixtures (contract_spec, agent_session, etc.)
py.typed # PEP 561 marker for type checker support
tests/ # 73 tests covering all contracts and edge cases
benchmarks/ # pytest-benchmark performance tests + stress tests
demo/ # Interactive demo (no API key needed)
pyproject.toml # Package metadata and build config
LICENSE # MIT
Technical decisions
Why pure Python with zero dependencies?
Every dependency is a potential security risk, a version conflict, and install-time overhead. AgentSpec uses only Python standard library (dataclasses, enum, typing, logging). When you pip install llm-agentspec, nothing else gets installed. That's on purpose.
Why method calls instead of YAML config?
YAML works for simple cases. But when you need conditional logic ("search must be called at most budget / cost_per_call times"), YAML breaks down. Python is a language engineers already know. And you get IDE autocomplete for free.
Why first-occurrence semantics for ordering?
must_call_before("search", "summarize") checks whether the FIRST call to "search" came before the FIRST call to "summarize". This catches the most common bug: the agent summarized before it searched. If you need different semantics, the library is designed to be extended.
Roadmap
Things I want to add next:
- Framework adapters - ready-made converters for Anthropic, OpenAI, LangChain, and OpenTelemetry traces so you don't have to build AgentSession manually
- Probabilistic contracts -
must_call_on_average(tool, n=2, over=100_runs)for contracts that should hold statistically - JUnit XML output - so contract results show up natively in GitHub Actions and Jenkins
- VS Code extension - inline visualization of contract results
Contributing
Contributions are welcome. If you want to add a new contract type, a framework adapter, or fix a bug:
- Fork the repo
- Create a branch
- Write tests for your change
- Make sure
pytest tests/ -vpasses - Open a PR
License
MIT. Use it however you want.
Built by Sai Rakshith Muda while building AI agents and getting frustrated that there was no simple way to test their behavior.
Project details
Release history Release notifications | RSS feed
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 llm_agentspec-0.2.0.tar.gz.
File metadata
- Download URL: llm_agentspec-0.2.0.tar.gz
- Upload date:
- Size: 28.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
552de4e72ee3c08031b682d2ce024511c9a17566c73e074a63e54170e519e70f
|
|
| MD5 |
51b1b963057c2e8d08c758191ca6932b
|
|
| BLAKE2b-256 |
19eb9f30426f6625a7937d0abd81f7d7e45d3a4b3c203b894867f3e687dc7d83
|
File details
Details for the file llm_agentspec-0.2.0-py3-none-any.whl.
File metadata
- Download URL: llm_agentspec-0.2.0-py3-none-any.whl
- Upload date:
- Size: 19.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
465329be4b74b7a8d9110676ac6a33b21570f36c028a0d6b5e65980d1436fd86
|
|
| MD5 |
fe0b068e007f16b3b488146e34d7f2f8
|
|
| BLAKE2b-256 |
b89d8cc3f345c55bcbc8623ff8c189d375d12405db7643f89195801e792a3e7c
|