Skip to content

Testing an Infinite Loop Guard in a Multi-Step Tool-Calling Agent

9 min read · updated August 11, 2026

A step limit is one line of code and nobody writes a test for it, because the failure it prevents is not one you can trigger by accident. You have to build a model that refuses to finish. That takes about six lines, and it is the only way to find out whether the guard is placed where you think it is.

What the guard has to be

The loop is: call the model, if it asked for tools run them and append the results, repeat. The guard is a counter around that, and where you put the check decides what happens when it trips.

type LoopResult =
  | { status: "complete"; text: string; steps: number }
  | { status: "step_limit"; steps: number; lastTool: string };

export async function runAgent(
  input: string,
  deps: { model: ModelFn; tools: Record<string, ToolFn> },
  maxSteps = 8,
): Promise<LoopResult> {
  const messages: Msg[] = [{ role: "user", content: input }];
  let lastTool = "";

  for (let step = 0; step < maxSteps; step++) {
    const res = await deps.model(messages);
    const calls = res.choices[0].message.tool_calls ?? [];
    if (calls.length === 0) {
      return { status: "complete", text: res.choices[0].message.content, steps: step + 1 };
    }
    messages.push(res.choices[0].message);
    for (const call of calls) {
      lastTool = call.function.name;
      const fn = deps.tools[call.function.name];
      const out = fn
        ? await fn(JSON.parse(call.function.arguments))
        : { error: "unknown_tool" };
      messages.push({
        role: "tool",
        tool_call_id: call.id,
        content: JSON.stringify(out),
      });
    }
  }
  return { status: "step_limit", steps: maxSteps, lastTool };
}

A for loop with a bound rather than a while (true) with a break is deliberate. The bound cannot be skipped by an early continue somebody adds later, and it cannot be defeated by a branch that forgets to increment. This matters more than it sounds: the classic version of this bug is a counter incremented inside the tool-dispatch branch, so a model that alternates between a tool call and an empty text turn advances the counter half as often as the loop actually runs.

The limit belongs in the signature with a default rather than as a module constant, and that is for the test’s sake more than the caller’s. A test that must run eight iterations to prove a guard works is eight times slower and eight times noisier than one that sets the limit to three. A constant you cannot override forces the slow version, or forces module mocking to reach it; taking the number as an argument makes the test a single extra parameter.

A model that never stops

The fake does one thing: it always returns the same tool call, forever. No queue, no script, no end.

function endlessModel(name = "search") {
  let n = 0;
  return async () => ({
    choices: [{
      finish_reason: "tool_calls",
      message: {
        role: "assistant",
        content: null,
        tool_calls: [{
          id: `call_${++n}`,
          type: "function",
          function: { name, arguments: JSON.stringify({ q: "same query" }) },
        }],
      },
    }],
  });
}

Incrementing the id matters. Both provider shapes require every tool call to be answered by a result carrying its id, and a fake that emits a constant id will pass a loop that mismatches ids — which is one of the bugs you are here to find. Give each call a distinct id and the loop has to thread them properly to work at all.

Note what the fake does not do: it never looks at the messages it is given. That is deliberate. A fake that changes behaviour based on conversation state is a second implementation of the thing you are testing, and when the test fails you will not know which of the two is wrong. Here the model is a constant function and the only moving part is your loop.

The test, bounded on both sides

Two assertions and one piece of insurance. The insurance is a timeout on the test itself: if the guard is broken, the loop does not fail, it hangs, and a hanging test in CI burns the job’s whole time budget before anyone learns anything.

import { describe, it, expect, vi } from "vitest";

describe("step limit", () => {
  it("stops a model that never finishes", { timeout: 2000 }, async () => {
    const search = vi.fn(async () => ({ hits: [] }));
    const model = vi.fn(endlessModel("search"));

    const res = await runAgent("find it", { model, tools: { search } }, 5);

    expect(res.status).toBe("step_limit");
    expect(res.steps).toBe(5);
    expect(model).toHaveBeenCalledTimes(5);   // not 5 + 1
    expect(search).toHaveBeenCalledTimes(5);
  });
});

The call-count assertions are the real content. status alone passes for a guard that runs one extra iteration before noticing, and an off-by-one there is a whole additional model call and tool execution per run in production. Assert the exact counts on both the model and the tool, because a guard that checks after dispatch will show 5 for one and 6 for the other.

Write the sibling test in the same file: the same loop, a fake that finishes on its third turn, and an assertion that the model was called exactly three times with a limit of five. A guard tested only against an endless model can be a guard that stops everything at step one, and the pair pins both edges. The passing case also documents what the limit costs you — if realistic conversations take four steps and the limit is five, there is one step of headroom, and any tool that starts returning partial answers will push ordinary traffic into the guard.

Vitest takes per-test options as the second argument, so the timeout sits between the name and the function. Check the signature your version documents before copying — the options-object form is newer than the trailing-number form and both have existed.

Three budgets a step count does not cover

  • Tokens. Every iteration appends the assistant message and the tool result to messages, so context grows monotonically and each step is more expensive than the last. Eight steps against a tool that returns a large document can cost more than eighty steps against one that returns a number. Track cumulative usage and stop on it as well.
  • Wall-clock. A loop that is within its step budget can still sit past a caller’s timeout, especially with a slow tool. A deadline computed once before the loop and checked at the top of each iteration bounds this without a second counter.
  • Repetition. The worst loops are short. A model that calls search with byte-identical arguments three times running has stopped making progress, and a step limit of eight lets it spend five more. Hash the tool name plus its arguments each step and stop on a repeat — that guard fires long before the outer one and has its own test, driven by the same endless fake.

What the loop returns when it trips

A guard that throws a bare Error is a guard whose caller cannot tell “the agent gave up” from “the provider returned 500”. Return a distinct status, carry the step count and the last tool attempted, and assert on those — lastTool is what makes a trip in production diagnosable, because the tool the model was stuck on is almost always the tool whose result did not answer its question.

Most agent frameworks ship a step or recursion limit of their own with a configurable maximum and a dedicated error type. If you are using one, this test still applies: point it at the framework’s entry point instead of your loop, assert on the error type its documentation names, and keep the endless fake. The thing being tested is that your configuration reaches the guard, which is exactly the part a framework cannot verify for you. Then check the opposite case: that a model which does finish is not made to run the extra lap.