n4nAI

A test harness for agents that call multiple tools per turn

Build a test harness for multi-tool agent turns: script LLM responses, fake tools, assert call ordering, and inject failures to validate agent resilience.

n4n Team3 min read746 words

Audio narration

Coming soon — every post will get a voice note here.

Building a reliable test harness for multi-tool agent turns is less about mocking LLM outputs and more about controlling the execution loop. When an agent invokes several tools in a single reasoning step, you need to assert on ordering, side effects, and partial failures. This guide lays out a practical test harness for multi-tool agent turns that you can drop into CI tomorrow.

Model the turn as an event stream

A multi-tool turn is not a single request/response pair. The assistant message may contain N tool_calls, each spawning an independent execution, and the subsequent user message carries N tool results. Treat the entire turn as a trace of structured events.

from dataclasses import dataclass, field
from typing import Any, List

@dataclass
class ToolCall:
    id: str
    name: str
    args: dict

@dataclass
class ToolResult:
    id: str
    ok: bool
    output: Any = None
    error: str | None = None

@dataclass
class TurnTrace:
    calls: List[ToolCall] = field(default_factory=list)
    results: List[ToolResult] = field(default_factory=list)

Capture this trace inside your agent loop. Every call the model emits and every result your runtime returns gets appended. Without this, you are guessing what the agent actually did.

Fake the tools, not the world

Do not point your tests at a real database or a live search API. Build a fake tool registry that records invocations and returns scripted outputs. Keep the interface identical to your production tool caller so the agent code path stays real.

class FakeToolRegistry:
    def __init__(self):
        self.calls: List[ToolCall] = []
        self.handlers = {}

    def register(self, name, fn):
        self.handlers[name] = fn

    def invoke(self, call: ToolCall) -> ToolResult:
        self.calls.append(call)
        try:
            out = self.handlers[call.name](**call.args)
            return ToolResult(id=call.id, ok=True, output=out)
        except Exception as e:
            return ToolResult(id=call.id, ok=False, error=str(e))

Pitfall: if your tool arguments contain mutable objects (a list of IDs, a dict config), the agent loop may mutate them after the call. Deep-copy call.args before storing, or your trace will lie about what was actually sent.

Script the model with OpenAI-compatible stubs

Live model calls make tests slow and flaky. Use a stub that emits predetermined assistant messages in the OpenAI tool-calling schema. Your agent should depend on a create() method, not a concrete SDK.

class StubChatClient:
    def __init__(self, scripted_messages):
        self.script = scripted_messages
        self.step = 0

    def create(self, **kwargs):
        msg = self.script[self.step]
        self.step += 1
        return msg

A scripted multi-tool response looks like this:

{
  "role": "assistant",
  "tool_calls": [
    {"id": "c1", "type": "function", "function": {"name": "search", "arguments": "{\"q\": \"weather\"}"}},
    {"id": "c2", "type": "function", "function": {"name": "calc", "arguments": "{\"expr\": \"1+1\"}"}}
  ]
}

Drive the first turn with that, then feed the faked ToolResults back into a second stubbed message that returns a final answer. Your test harness for multi-tool agent turns now runs in milliseconds.

Assert on ordering and concurrency

If your runtime executes tool calls sequentially, assert exact order:

def test_multi_tool_order():
    reg = FakeToolRegistry()
    reg.register("search", lambda q: ["rain"])
    reg.register("calc", lambda expr: 2)
    # run agent with StubChatClient from previous section
    assert [c.name for c in reg.calls] == ["search", "calc"]

If you allow parallel dispatch, order is nondeterministic. Assert on the set of called tools and on a concurrency ceiling:

assert {c.name for c in reg.calls} == {"search", "calc"}
assert len(reg.calls) == 2

Tradeoff: parallel execution improves latency but complicates debugging. Your test harness should expose a flag to force sequential mode so failures are reproducible.

Inject failures and test fallback

Real tools fail. A robust agent must handle a timeout on one tool while still using the other. Register a handler that raises:

def flaky(**kwargs):
    raise RuntimeError("timeout")
reg.register("search", flaky)

Now assert the turn trace contains one failed ToolResult and the agent either degrades gracefully or aborts with a controlled error. Do not let the test pass if the exception escapes uncaught.

If you exercise the harness against live models, a gateway such as n4n.ai that honors client routing directives lets you force a specific provider into a degraded state, exercising fallback logic without custom proxies. The harness itself stays provider-agnostic.

Snapshot the full trace

Once the turn completes, compare the entire TurnTrace against a stored fixture. This catches regressions in argument serialization, result shaping, or ordering.

def test_snapshot(turn_trace):
    assert turn_trace == TurnTrace(
        calls=[
            ToolCall("c1", "search", {"q": "weather"}),
            ToolCall("c2", "calc", {"expr": "1+1"})
        ],
        results=[
            ToolResult("c1", True, ["rain"]),
            ToolResult("c2", True, 2)
        ]
    )

Keep the snapshot in a version-controlled JSON file. When the agent logic changes intentionally, update the snapshot via a flagged test run, not by hand-editing.

Run in CI with budgets

Wire the harness into your pipeline as a normal pytest suite. Add a per-turn timeout to catch infinite tool loops:

import pytest

@pytest.mark.timeout(5)
def test_turn_completes():
    # harness run

For broader coverage, parameterize the stub across multiple scripted scenarios: happy path, partial failure, malformed arguments, and rate-limit simulation. The test harness for multi-tool agent turns becomes a matrix, not a single script.

Common pitfalls

  • Assuming tool calls are sequential. Many frameworks dispatch in parallel; your assertions must reflect that.
  • Not deep-copying args. Mutable state corrupts the recorded trace.
  • Ignoring token metering. If you route through a gateway with per-token usage metering, assert that usage fields appear in traces when testing live paths.
  • Over-mocking the model. Stub only the transport. Keep prompt assembly and response parsing real.
  • Skipping cleanup. Fake registries are global state; reset them per test or you will get cross-test leakage.

Tradeoffs

A pure stub harness is fast and deterministic but does not validate that your prompts actually elicit multi-tool calls from a real model. A live harness validates that, but costs tokens and adds flakiness. The pragmatic split: run stubbed multi-tool scenarios on every commit, and run a nightly live smoke test that checks at least one real multi-tool turn against a cheap model.

The test harness for multi-tool agent turns is a layered system: event capture, fake tools, scripted model, assertion logic, and CI glue. Build it once, and agent refactors stop being scary.

Tagsagent-testingtool-callingtest-harnessllm-agents

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All testing ai agents & tool calling posts →