Skip to main content

A modern multi-agent AI testing framework

Project description

Tenro

PyPI version Python 3.11+ License

Tenro is a modern simulation framework for testing AI agents. Simulate multi-agent workflows and tool usage without burning tokens.

  • No API costs — Tests run offline (no LLM calls)
  • Deterministic — Simulate responses, errors, and tool results
  • Workflow verification — Check tools, edge cases, and agent behaviours

Install

pip install tenro
# or
uv add tenro

Quick Start

Tenro provides a construct pytest fixture that intercepts LLM and tool calls during tests.

# myapp/agent.py
from tenro import link_agent, link_tool

@link_tool
def search(query: str) -> list[str]:
    ...  # calls external API

@link_agent("Assistant", entry_points="run")
class AssistantAgent:
    def run(self, task: str) -> str:
        ...  # agent loop: LLM calls tools, returns final answer
# tests/test_agent.py
from tenro import Provider
from tenro.simulate import llm, tool, tc
from myapp.agent import search, AssistantAgent

def test_agent(construct):
    tool.simulate(search, result=["Simulated Doc"])
    llm.simulate(
        Provider.ANTHROPIC,
        responses=[
            {"tool_calls": [tc(search, query="Find docs")]},
            "Summary of docs.",
        ],
    )

    result = AssistantAgent().run("Find docs")

    assert result == "Summary of docs."
    tool.verify(search)
    llm.verify_many(Provider.ANTHROPIC, count=2)

No mocks to configure, no expensive API calls, no flaky tests.

Before / After

Without Tenro — manual mocks, helper functions, boilerplate:

# test_helpers.py - you write and maintain this
def mock_llm_response(content=None, tool_call=None):
    if tool_call:
        message = ChatCompletionMessage(
            role="assistant", content=None,
            tool_calls=[ChatCompletionMessageToolCall(
                id="call_abc", type="function",
                function=Function(name=tool_call["name"], arguments=json.dumps(tool_call["args"]))
            )]
        )
    else:
        message = ChatCompletionMessage(role="assistant", content=content, tool_calls=None)
    return ChatCompletion(
        id="chatcmpl-123", created=0, model="gpt-5", object="chat.completion",
        choices=[Choice(index=0, finish_reason="stop", message=message)]
    )

# test_agent.py
@patch("myapp.tools.get_weather")
@patch("openai.chat.completions.create")
def test_agent(mock_llm, mock_weather):
    mock_weather.return_value = {"temp": 72, "condition": "sunny"}
    mock_llm.side_effect = [
        mock_llm_response(tool_call={"name": "get_weather", "args": {"city": "Paris"}}),
        mock_llm_response(content="It's 72°F and sunny in Paris."),
    ]
    result = my_agent.run("Weather in Paris?")
    assert result == "It's 72°F and sunny in Paris."
    mock_weather.assert_called_once_with(city="Paris")

With Tenro:

from tenro import Provider
from tenro.simulate import llm, tool, tc
from myapp.agent import get_weather, WeatherAgent

def test_agent(construct):
    tool.simulate(get_weather, result={"temp": 72, "condition": "sunny"})
    llm.simulate(
        Provider.OPENAI,
        responses=[
            {"tool_calls": [tc(get_weather, city="Paris")]},
            "It's 72°F and sunny in Paris.",
        ],
    )

    result = WeatherAgent().run("Weather in Paris?")

    tool.verify(get_weather)
    llm.verify_many(Provider.OPENAI, count=2)
    assert result == "It's 72°F and sunny in Paris."

No patch decorators. No response builders. Just simulate and verify.

How It Works

Tenro's Construct is a simulation environment for your AI agents. Link your functions with decorators, then test with full control:

from tenro import link_agent, link_tool

@link_tool
def search(query: str) -> list[str]:
    ...  # calls external API

@link_agent("Manager", entry_points="run")
class ManagerAgent:
    def run(self, task: str) -> str:
        ...  # LLM calls search tool, summarizes results

During tests, Construct intercepts linked LLM and tool calls and returns your simulated results instead of calling the real provider.

Simulation API

from tenro import Provider
from tenro.simulate import llm, tool, tc
from myapp.agent import search, MyAgent

def test_verification(construct):
    # Setup
    tool.simulate(search, result=["doc1", "doc2"])
    llm.simulate(
        Provider.ANTHROPIC,
        responses=[
            {"tool_calls": [tc(search, query="docs")]},
            "Summary",
        ],
    )

    # Run
    MyAgent().run("query")

    # Verify
    tool.verify(search)                              # at least once
    tool.verify_many(search, count=1)                # exactly once
    llm.verify_many(Provider.ANTHROPIC, count=2)     # exactly twice

    # Access call data
    assert llm.calls()[1].response == "Summary"

Trace Output

Enable trace visualization to debug agent execution:

Set TENRO_TRACE=true in your .env or run TENRO_TRACE=true pytest

🤖 SupportAgent
   ├─ → user: "My order #12345 hasn't arrived"
   │
   ├─ 🧠 claude-sonnet-4-5
   │     ├─ → prompt: "Help customer: My order #12345 hasn't arrived"
   │     └─ ← tool_call: lookup_order(order_id='12345')
   │
   ├─ 🔧 lookup_order
   │     ├─ → order_id='12345'
   │     └─ ← {'status': 'shipped', 'eta': '2025-01-02'}
   │
   ├─ 🧠 claude-sonnet-4-5
   │     ├─ → prompt: "Tool result: {'status': 'shipped', ...}"
   │     └─ ← "Your order has shipped and will arrive by Jan 2nd!"
   │
   └─ ← "Your order has shipped and will arrive by Jan 2nd!"

────────────────────────────────────────────────────────────────
Summary: 1 agent | 2 LLM calls | 1 tool call | Total: 1.24s

LLM Provider Support

Provider Status
OpenAI
Anthropic
Gemini
Custom Experimental

Compatibility

  • Python 3.11+
  • pytest 7.0+

Contributing

Thanks for your interest in contributing!

Tenro is still in the early stages, focused on stabilizing the core API. Pull requests are not being accepted at this time.

You can still help by:

  • ⭐ Star the repo to follow progress and help others discover it
  • Report bugs (include repro steps + logs if possible)
  • Request features (share the use case and expected behavior)
  • Ask questions (usage, roadmap, design decisions)

Please use GitHub Issues for discussions and reports.

License

Apache 2.0

Support

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

tenro-0.2.0.tar.gz (130.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

tenro-0.2.0-py3-none-any.whl (187.7 kB view details)

Uploaded Python 3

File details

Details for the file tenro-0.2.0.tar.gz.

File metadata

  • Download URL: tenro-0.2.0.tar.gz
  • Upload date:
  • Size: 130.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.9.18 {"installer":{"name":"uv","version":"0.9.18","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for tenro-0.2.0.tar.gz
Algorithm Hash digest
SHA256 75d3f09cb5601fd023ab8570964722a941e1c0e04e431c2c24de9d1b32575c74
MD5 97d85da56e866cd7b6743fa8c41678ef
BLAKE2b-256 376f9fa7e7dc8407b8823f3095526e24f9a3aeee79ae2f311310818f772d56a2

See more details on using hashes here.

File details

Details for the file tenro-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: tenro-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 187.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.9.18 {"installer":{"name":"uv","version":"0.9.18","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for tenro-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 5f8a98382a4b48619f39379de277e1a23530a4745c7baeedc75285a89d3225e3
MD5 abd9aa1f0a9da2b1d642a6e55fb88759
BLAKE2b-256 6b962e6bf42e3c7b27cb5b56bc6b4881cce9a4c34dafa39b74d773b739168240

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page