Skip to content

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.

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_calls or construct.tool_calls for inspection
  • Writing helper functions that accept Construct as 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 Construct as 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
class Construct(_ConstructImpl):
    """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()
    """

    pass

See also