tenro.construct¶
Test harness for simulating and verifying AI agent behaviour.
The Construct class is the core of Tenro's testing capabilities. Use it to simulate LLM responses, tool results, and verify your agent behaved correctly.
Quick example¶
from tenro import link_tool, Provider, ToolCall
from tenro.simulate import llm, tool, agent
from tenro.testing import tenro
@link_tool("search")
def search(query: str) -> list[str]:
return api.search(query)
@tenro
def test_agent_workflow():
# Simulate tool result
tool.simulate(search, result=["doc1", "doc2"])
# LLM requests tool (1st response), then answers (2nd response)
llm.simulate(Provider.OPENAI, responses=[
ToolCall(search, query="documents"),
"Summary of docs",
])
# Run your agent
my_agent.run("Find documents")
# Verify behaviour
tool.verify_many(search, count=1)
llm.verify_many(Provider.OPENAI, count=2) # Tool request + final answer
Common patterns¶
Simulating responses¶
from tenro import Provider, link_tool
from tenro.simulate import llm, tool
@link_tool("search")
def search(query: str) -> list[str]:
return api.search(query)
# Single LLM response (same every call)
llm.simulate(Provider.OPENAI, response="Hello")
# Sequential LLM responses (different each call)
llm.simulate(Provider.OPENAI, responses=["First", "Second", "Third"])
# Tool with single result
tool.simulate(search, result=["doc1", "doc2"])
# Tool with sequential results
tool.simulate(search, results=[[], ["doc1"]])
# Dynamic result based on input
tool.simulate(search, side_effect=lambda q: [f"Result for {q}"])
Simulating agentic loops¶
When the LLM decides to call a tool, then responds with the result:
from tenro import Provider, link_tool, ToolCall
from tenro.simulate import llm, tool
from tenro.testing import tenro
@link_tool("get_weather")
def get_weather(city: str) -> dict:
return weather_api.fetch(city)
@tenro
def test_weather_agent_uses_tool():
# Set up tool result first
tool.simulate(get_weather, result={"temp": 72, "condition": "sunny"})
# LLM requests tool (1st response), then answers (2nd response)
llm.simulate(Provider.OPENAI, responses=[
ToolCall(get_weather, city="Paris"),
"It's 72°F and sunny in Paris!",
])
result = weather_agent.run("What's the weather in Paris?")
tool.verify(get_weather, city="Paris")
assert "72" in result
Simulating errors¶
Test how your agent handles failures:
from tenro import Provider, link_llm, link_tool, ToolCall
from tenro.simulate import llm, tool
from tenro.testing import tenro
@link_tool("api_call")
def api_call() -> dict:
return external_api.call()
@link_llm(Provider.OPENAI)
def call_openai(prompt: str) -> str:
return openai.chat.completions.create(...)
@tenro
def test_agent_handles_rate_limit():
# Simulate API error (use_http=False requires a @link_llm target)
llm.simulate(
target=call_openai,
responses=[ConnectionError("Rate limited")],
use_http=False,
)
result = my_agent.run("Hello")
assert "try again" in result.lower()
@tenro
def test_agent_retries_on_timeout():
# First tool call fails, second succeeds
tool.simulate(api_call, results=[
ConnectionError("Timeout"),
{"status": "ok"},
])
# LLM tries tool, gets error, retries, succeeds, then responds
llm.simulate(Provider.OPENAI, responses=[
ToolCall(api_call),
ToolCall(api_call),
"API call completed successfully.",
])
result = resilient_agent.run("Make API call")
tool.verify_many(api_call, count=2)
assert "successfully" in result
Verifying behaviour¶
After running your agent, verify it behaved correctly:
from tenro import Provider, link_tool, ToolCall
from tenro.simulate import llm, tool
from tenro.testing import tenro
@link_tool("search")
def search(query: str) -> list[str]:
return api.search(query)
@link_tool("dangerous_operation")
def dangerous_operation() -> None:
# Something risky
pass
@tenro
def test_agent_workflow():
# Set up tool result first
tool.simulate(search, result=["doc1", "doc2"])
# LLM requests tool, then responds with final answer
llm.simulate(Provider.OPENAI, responses=[
ToolCall(search, query="AI trends"),
"Based on the documents, AI is advancing rapidly.",
])
my_agent.run("Summarize AI trends")
# Verify agent behavior
tool.verify_many(search, count=1)
llm.verify_many(Provider.OPENAI, count=2) # Tool request + final answer
@tenro
def test_agent_doesnt_call_dangerous_tool():
llm.simulate(Provider.OPENAI, response="I cannot do that.")
safe_agent.run("Do something risky")
# Verify dangerous tool was never called
tool.verify_never(dangerous_operation)
Verifying call sequence¶
When your agent must perform operations in a specific order:
from tenro import Provider, link_tool, ToolCall
from tenro.simulate import llm, tool
from tenro.testing import tenro
@link_tool("fetch")
def fetch() -> str:
return data_source.fetch()
@link_tool("save")
def save(data: str) -> str:
return storage.save(data)
@tenro
def test_pipeline_order():
# Set up tool results
tool.simulate(fetch, result="raw data")
tool.simulate(save, result="ok")
# LLM fetches, then saves, then responds
llm.simulate(Provider.OPENAI, responses=[
ToolCall(fetch),
ToolCall(save, data="processed"),
"Done! Data has been processed and saved.",
])
pipeline_agent.run("Process and save data")
# Verify tools were called in the right order
tool.verify_sequence([fetch, save])
Pytest fixture patterns¶
The construct fixture activates Tenro's simulation layer for each test. There are several ways to apply it depending on your needs.
Per-test (recommended)¶
Use the @tenro decorator to enable the construct fixture:
from tenro.testing import tenro
from tenro.simulate import llm, tool
@tenro
def test_agent_workflow():
tool.simulate(search, result=["doc1"])
llm.simulate(Provider.OPENAI, response="Hello")
my_agent.run()
tool.verify(search)
@tenro is an alias for @pytest.mark.usefixtures("construct"). This approach:
- Avoids IDE "unused argument" warnings
- Makes the dependency explicit
- Keeps tests self-documenting
Per-class¶
Apply the decorator to a test class to cover all methods:
from tenro.testing import tenro
@tenro
class TestAgentBehavior:
def test_handles_success(self):
llm.simulate(Provider.OPENAI, response="Done")
...
def test_handles_error(self):
llm.simulate(Provider.OPENAI, response=ConnectionError("Failed"))
...
Per-module¶
Add at the top of a test file to apply to all tests in that module:
# tests/test_agent.py
from tenro.testing import tenro
pytestmark = tenro
def test_one():
...
def test_two():
...
When to pass construct as a parameter¶
Only pass construct as a parameter when you need direct access to the harness object:
from tenro import Construct
def test_custom_provider(construct: Construct):
# Register a custom provider (requires construct access)
construct.register_provider("mistral", adapter=Provider.OPENAI)
llm.simulate("mistral", response="Hello from Mistral!")
...
Common use cases for direct access:
- Registering custom providers
- Accessing
construct.llm_callsorconstruct.tool_callsfor inspection - Writing helper functions that accept
Constructas a parameter
Using without pytest¶
The module API works with the pytest construct fixture automatically. For scripts or other test frameworks, use Construct as a context manager:
from tenro import Construct, Provider
from tenro.simulate import llm, tool
# Without pytest - use context manager
with Construct():
tool.simulate(search, result=["doc1"])
llm.simulate(Provider.OPENAI, response="Hello")
my_agent.run()
tool.verify(search)
llm.verify(Provider.OPENAI)
The module API (llm.simulate, tool.verify) works the same inside the context manager.
Direct Construct methods (advanced)¶
You can also call methods directly on the construct object. Prefer the module API unless you need direct access to the harness object.
@tenro
def test_example():
construct.simulate_tool(search, result=["doc1"])
construct.simulate_llm(Provider.OPENAI, response="Hello")
my_agent.run()
construct.verify_tool(search)
This is useful when:
- Writing helper functions that take
Constructas a parameter - Working with multiple constructs at once
- You prefer seeing all methods on one object
Reference¶
Construct class for LLM testing.
Provides the Construct class for simulating and verifying LLM, tool,
and agent calls in tests.
Construct
¶
Bases: Construct
Test harness for simulating and verifying LLM, tool, and agent calls.
Use as a pytest fixture or context manager:
def test_example(construct):
construct.simulate_llm(Provider.OPENAI, response="Hello")
# ... run agent code ...
construct.verify_llm(Provider.OPENAI)
For manual context management (non-pytest):
async with Construct() as construct:
construct.simulate_llm(Provider.ANTHROPIC, response="Hi")
await my_agent.run()
construct.verify_llm()
Source code in tenro/construct.py
See also¶
- Testing patterns: More simulation and verification patterns
- Examples: Real-world usage examples