Resource library

QA How-To

Test AI Agent Loop Termination: A Practical Tutorial

Learn how to test AI agent loop termination with deterministic fixtures, iteration limits, stop reasons, timeout guards, traces, and reliable assertions.

18 min read | 2,501 words

TL;DR

Test every supported stop condition with scripted model responses, assert the stop reason and final state, and verify that no model or tool call occurs after termination. Add independent iteration, repetition, and deadline guards so malformed model output cannot create an infinite loop.

Key Takeaways

  • Model termination as an explicit result with a machine-readable stop reason.
  • Test success, refusal, iteration-limit, repeated-action, and deadline exits separately.
  • Use scripted model responses so loop-control tests stay deterministic.
  • Count model calls and tool calls to detect hidden work after termination.
  • Keep a hard iteration cap even when semantic completion checks exist.
  • Record a trace that explains exactly why the agent stopped.

To test AI agent loop termination, treat stopping as observable product behavior, not an implementation detail. Your test must prove when the agent stops, why it stops, what state it returns, and that it performs no more model or tool calls afterward.

This tutorial builds a small TypeScript agent runner with deterministic tests for successful completion, refusal, iteration exhaustion, repeated actions, and deadlines. For the broader strategy around tools, memory, safety, and evaluation, read the AI Agent Testing Complete Guide for 2026.

You will use Vitest and a scripted model adapter rather than a live model. That choice makes loop-control behavior fast, reproducible, and easy to diagnose while leaving live-model evaluations for a separate test layer.

What You Will Build

You will build a minimal but production-shaped agent loop and a focused termination test suite. By the end, you will have:

  • A typed agent result with explicit completed, refused, max_iterations, repeated_action, and deadline_exceeded stop reasons.
  • A scripted model that returns a known sequence of actions without network calls.
  • Tool execution with trace events and call counters.
  • A hard iteration guard, repeated-action detector, and injectable clock.
  • Assertions that prove the loop stopped and did not perform hidden work afterward.

The example uses a calculator tool because its behavior is easy to inspect. The same loop-control design applies to browser agents, coding agents, support agents, and multi-tool workflows.

Prerequisites

Use Node.js 22 or a supported newer LTS release, npm, and TypeScript 5.7 or later. Start in an empty folder:

mkdir agent-loop-termination
cd agent-loop-termination
npm init -y
npm install -D typescript vitest @types/node
npx tsc --init

Add these scripts and module settings to package.json:

{
  "type": "module",
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest"
  }
}

Create a src directory. The tutorial keeps model and tool interfaces local so you can run it without credentials. You should already be comfortable with promises, discriminated unions, and basic Vitest assertions.

Verify: Run npx tsc --version and npx vitest --version. Both commands should print versions without an installation error.

Step 1: Define Explicit Termination Contracts

Start by making termination part of the public API. A boolean such as done: true is insufficient because it cannot distinguish success from a safety refusal or guardrail exit. Create src/types.ts:

export type ModelDecision =
  | { type: "tool"; tool: string; input: unknown }
  | { type: "final"; answer: string }
  | { type: "refusal"; reason: string };

export type StopReason =
  | "completed"
  | "refused"
  | "max_iterations"
  | "repeated_action"
  | "deadline_exceeded";

export interface TraceEvent {
  iteration: number;
  kind: "decision" | "tool_result" | "stop";
  detail: string;
}

export interface AgentResult {
  stopReason: StopReason;
  answer?: string;
  iterations: number;
  modelCalls: number;
  toolCalls: number;
  trace: TraceEvent[];
}

export interface ModelPort {
  decide(context: readonly TraceEvent[]): Promise<ModelDecision>;
}

export type Tool = (input: unknown) => Promise<unknown>;

The union forces callers to handle every supported outcome. Counters expose loop behavior without requiring a test to inspect private variables. The trace supplies evidence for failures and production telemetry.

Do not use thrown exceptions for expected exits such as iteration exhaustion. Exceptions are appropriate for unexpected infrastructure failure. Expected termination is domain data and should be asserted directly.

Verify: Run npx tsc --noEmit. The command should exit with code 0. If the default TypeScript configuration includes unrelated folder errors, set include to ["src"].

Step 2: Create a Deterministic Scripted Model

A live model can choose a different action between runs, which makes a loop-control unit test unreliable. Build an adapter that consumes decisions in order and records calls. Create src/scripted-model.ts:

import type { ModelDecision, ModelPort, TraceEvent } from "./types.js";

export class ScriptedModel implements ModelPort {
  public calls = 0;
  private cursor = 0;

  constructor(private readonly script: readonly ModelDecision[]) {}

  async decide(_context: readonly TraceEvent[]): Promise<ModelDecision> {
    this.calls += 1;
    const decision = this.script[this.cursor];
    this.cursor += 1;

    if (!decision) {
      throw new Error("ScriptedModel ran out of decisions");
    }
    return decision;
  }
}

This is a test double for the model boundary, not a mock of the entire agent. It preserves the important orchestration path: request a decision, execute a tool, append context, and request the next decision. Only probabilistic inference is replaced.

Running out of decisions deliberately throws. If a test expects two model calls but the loop makes a third, it fails immediately instead of silently returning a convenient response. That makes unwanted continuation visible.

For provider integration tests, record sanitized responses or use a provider sandbox. Keep those tests separate because they answer a different question: whether your adapter correctly converts provider output into ModelDecision.

Verify: Add a temporary TypeScript check or rely on the tests added later. npx tsc --noEmit should still pass, and ScriptedModel.calls should be publicly readable.

Step 3: Implement the Agent Loop and Happy-Path Exit

Create src/agent.ts with a hard iteration boundary. The loop checks the deadline before each model request, asks for a decision, and returns immediately for terminal decisions.

import type {
  AgentResult, ModelPort, StopReason, Tool, TraceEvent
} from "./types.js";

export interface RunOptions {
  maxIterations: number;
  deadlineMs: number;
  now?: () => number;
  repeatedActionLimit?: number;
}

export async function runAgent(
  model: ModelPort,
  tools: Readonly<Record<string, Tool>>,
  options: RunOptions
): Promise<AgentResult> {
  const now = options.now ?? Date.now;
  const startedAt = now();
  const trace: TraceEvent[] = [];
  let modelCalls = 0;
  let toolCalls = 0;
  let previousAction = "";
  let repeatedActions = 0;

  const finish = (
    stopReason: StopReason, iterations: number, answer?: string
  ): AgentResult => {
    trace.push({ iteration: iterations, kind: "stop", detail: stopReason });
    return { stopReason, answer, iterations, modelCalls, toolCalls, trace };
  };

  for (let iteration = 1; iteration <= options.maxIterations; iteration += 1) {
    if (now() - startedAt >= options.deadlineMs) {
      return finish("deadline_exceeded", iteration - 1);
    }

    modelCalls += 1;
    const decision = await model.decide(trace);
    trace.push({ iteration, kind: "decision", detail: JSON.stringify(decision) });

    if (decision.type === "final") {
      return finish("completed", iteration, decision.answer);
    }
    if (decision.type === "refusal") {
      return finish("refused", iteration, decision.reason);
    }

    const signature = JSON.stringify([decision.tool, decision.input]);
    repeatedActions = signature === previousAction ? repeatedActions + 1 : 1;
    previousAction = signature;
    if (repeatedActions >= (options.repeatedActionLimit ?? 3)) {
      return finish("repeated_action", iteration);
    }

    const tool = tools[decision.tool];
    if (!tool) throw new Error(`Unknown tool: ${decision.tool}`);
    toolCalls += 1;
    const output = await tool(decision.input);
    trace.push({ iteration, kind: "tool_result", detail: JSON.stringify(output) });
  }

  return finish("max_iterations", options.maxIterations);
}

Notice that every terminal branch uses return. Setting a flag and allowing the function to fall through can accidentally trigger another model call. Also notice that the repeated-action check happens before tool execution. The triggering duplicate is observed but not executed, which reduces side effects.

Verify: Run npx tsc --noEmit. Confirm there is one finish call for every normal exit path and that all fields in AgentResult are populated.

Step 4: Test AI Agent Loop Termination on Completion

Create src/agent.test.ts. The first test proves the normal two-iteration sequence: use a tool once, then return a final answer.

import { describe, expect, it, vi } from "vitest";
import { runAgent } from "./agent.js";
import { ScriptedModel } from "./scripted-model.js";

describe("agent loop termination", () => {
  it("stops immediately after a final answer", async () => {
    const model = new ScriptedModel([
      { type: "tool", tool: "add", input: { a: 2, b: 3 } },
      { type: "final", answer: "The total is 5." }
    ]);
    const add = vi.fn(async (input: unknown) => {
      const { a, b } = input as { a: number; b: number };
      return a + b;
    });

    const result = await runAgent(model, { add }, {
      maxIterations: 5, deadlineMs: 1_000
    });

    expect(result).toMatchObject({
      stopReason: "completed",
      answer: "The total is 5.",
      iterations: 2,
      modelCalls: 2,
      toolCalls: 1
    });
    expect(model.calls).toBe(2);
    expect(add).toHaveBeenCalledOnce();
    expect(result.trace.at(-1)?.detail).toBe("completed");
  });
});

The stop-reason assertion proves the outcome. The counters prove the path. The spy proves the tool was not called after completion. These assertions complement each other and make regressions easier to locate.

Avoid asserting only the final text. An agent might produce the right text after an unnecessary extra cycle, causing extra cost or duplicate side effects. Conversely, asserting only call counts could miss an incorrect public result.

Verify: Run npm test. Vitest should report one passing test. Temporarily remove return from the final branch and observe that compilation or runtime behavior fails, then restore it.

Step 5: Test Refusal and Maximum-Iteration Exits

Add two tests inside the existing describe block. A refusal is terminal even when the agent could theoretically ask another model turn. Maximum iterations must stop a model that continuously requests valid but changing actions.

it("stops on a refusal without calling a tool", async () => {
  const model = new ScriptedModel([
    { type: "refusal", reason: "Request violates policy." }
  ]);
  const tool = vi.fn(async () => "unused");

  const result = await runAgent(model, { tool }, {
    maxIterations: 5, deadlineMs: 1_000
  });

  expect(result.stopReason).toBe("refused");
  expect(result.answer).toBe("Request violates policy.");
  expect(result.modelCalls).toBe(1);
  expect(result.toolCalls).toBe(0);
  expect(tool).not.toHaveBeenCalled();
});

it("stops at the hard iteration limit", async () => {
  const model = new ScriptedModel([
    { type: "tool", tool: "lookup", input: { page: 1 } },
    { type: "tool", tool: "lookup", input: { page: 2 } },
    { type: "tool", tool: "lookup", input: { page: 3 } }
  ]);
  const lookup = vi.fn(async () => ({ found: false }));

  const result = await runAgent(model, { lookup }, {
    maxIterations: 3, deadlineMs: 1_000
  });

  expect(result.stopReason).toBe("max_iterations");
  expect(result.iterations).toBe(3);
  expect(result.modelCalls).toBe(3);
  expect(result.toolCalls).toBe(3);
  expect(lookup).toHaveBeenCalledTimes(3);
});

The hard cap is a last-resort guard, not the primary definition of success. Choose it from the workflow's expected number of useful decisions, tool risk, latency budget, and cost budget. A research agent may need more iterations than a single-record lookup agent.

Keep boundary semantics precise. In this implementation, maxIterations: 3 permits exactly three model decisions and three possible tool executions. Document that rule so tests, dashboards, and on-call engineers interpret the count consistently.

Verify: Run npm test. You should see three passing tests. Change the cap to two in the second test and confirm that all observed counts become two.

Step 6: Detect Repeated-Action Loops Before Side Effects

An iteration cap limits damage, but it does not identify the common pattern where the model repeats the same tool request. Add this test:

it("stops before executing the third identical action", async () => {
  const repeated = {
    type: "tool" as const,
    tool: "sendEmail",
    input: { to: "qa@example.com", subject: "Status" }
  };
  const model = new ScriptedModel([repeated, repeated, repeated]);
  const sendEmail = vi.fn(async () => ({ accepted: true }));

  const result = await runAgent(model, { sendEmail }, {
    maxIterations: 10,
    deadlineMs: 1_000,
    repeatedActionLimit: 3
  });

  expect(result.stopReason).toBe("repeated_action");
  expect(result.iterations).toBe(3);
  expect(result.modelCalls).toBe(3);
  expect(result.toolCalls).toBe(2);
  expect(sendEmail).toHaveBeenCalledTimes(2);
});

The third identical request triggers termination before the third side effect. In a real system, make consequential tools idempotent too. A stable idempotency key can prevent duplicate payment, message, or ticket creation even if orchestration safeguards fail.

JSON serialization is adequate for this controlled fixture, but production signatures need canonicalization. Object key order, ignored metadata, timestamps, or semantically equivalent URLs can hide repetition. Normalize the tool name and meaningful arguments, sort object keys, then hash the canonical representation.

Repeated-action policy also needs workflow context. Two identical read calls may be harmless retries, while two identical money transfers are unacceptable. Configure thresholds by tool risk rather than applying one global number blindly.

Verify: Run npm test. Confirm the model has three calls but the tool has only two. This difference proves the guard runs before side effects.

Step 7: Test the Deadline with an Injectable Clock

Wall-clock tests that sleep are slow and flaky. Inject now and advance a fake numeric clock from a tool. Add this test:

it("stops before another model call when the deadline expires", async () => {
  let time = 10_000;
  const model = new ScriptedModel([
    { type: "tool", tool: "slowSearch", input: "first" },
    { type: "final", answer: "This must not be reached." }
  ]);
  const slowSearch = vi.fn(async () => {
    time += 500;
    return [];
  });

  const result = await runAgent(model, { slowSearch }, {
    maxIterations: 5,
    deadlineMs: 500,
    now: () => time
  });

  expect(result.stopReason).toBe("deadline_exceeded");
  expect(result.iterations).toBe(1);
  expect(result.modelCalls).toBe(1);
  expect(result.toolCalls).toBe(1);
  expect(model.calls).toBe(1);
});

The deadline is checked between iterations, so an already running tool is allowed to finish. If you must bound individual model or tool calls, pass an AbortSignal and use an abort-aware timeout around each adapter call. A loop deadline and an operation timeout solve related but distinct problems.

The >= comparison defines the boundary: exactly 500 elapsed milliseconds is expired. Test this equality case deliberately. Off-by-one behavior at time boundaries often remains invisible when tests use only comfortably smaller or larger values.

Use a monotonic clock for elapsed time in production when your runtime offers one. Calendar time can jump because of synchronization or administrative changes. Keep the injected function so unit tests stay instant.

Verify: Run npm test. The scripted final response must remain unused. If model.calls becomes two, the deadline check is in the wrong location.

Step 8: Complete Test AI Agent Loop Termination Coverage

Finish with a compact matrix that verifies terminal decisions from the first model call. Table-driven tests reduce duplication and make a missing stop reason obvious.

it.each([
  {
    name: "completion",
    decision: { type: "final" as const, answer: "done" },
    expected: "completed"
  },
  {
    name: "refusal",
    decision: { type: "refusal" as const, reason: "blocked" },
    expected: "refused"
  }
])("records a stop trace for $name", async ({ decision, expected }) => {
  const model = new ScriptedModel([decision]);
  const result = await runAgent(model, {}, {
    maxIterations: 1, deadlineMs: 1_000
  });

  expect(result.stopReason).toBe(expected);
  expect(result.trace.map((event) => event.kind)).toEqual([
    "decision", "stop"
  ]);
  expect(result.trace.at(-1)).toMatchObject({
    iteration: 1, kind: "stop", detail: expected
  });
});

Use the following coverage matrix when extending the suite:

Exit path Trigger Essential assertions Forbidden work
Completed Final model decision Answer, reason, iteration Later model or tool call
Refused Refusal decision Policy reason, zero tool calls Tool execution
Max iterations Loop reaches configured cap Exact boundary counts Call beyond cap
Repeated action Canonical signature reaches threshold Duplicate count, trace Triggering side effect
Deadline Elapsed time reaches budget Deadline reason, elapsed boundary Next model request

Also add property-based or generated tests if your loop has many state combinations. Generate bounded decision sequences, run them with a small cap, and assert that every run returns within that cap. Keep a deterministic seed so any failure can be replayed.

Trace assertions should focus on stable facts such as event order, iteration, stop reason, tool name, and correlation ID. Avoid snapshots of full prompts or provider payloads because harmless formatting changes create noisy failures and traces may contain sensitive data.

Verify: Run npm test -- --reporter=verbose. Every case should pass, and each terminal run should end with exactly one stop event. Run npx tsc --noEmit again to verify the complete project.

Troubleshooting

The test hangs instead of reaching the iteration cap -> Check for an unbounded inner retry inside the model or tool adapter. The outer loop cannot enforce its cap while awaiting a promise that never settles. Add abort-aware operation timeouts and make retry counts finite.

The tool runs once after a final answer -> Return directly from the terminal branch. Do not store the decision in mutable state and continue to shared tool-execution code. Add explicit zero-call or exact-call assertions for every terminal path.

Repeated actions are not detected -> Canonicalize inputs before comparing them. Remove volatile values, sort keys, normalize URLs, and compare only arguments that define semantic identity. Log the canonical signature in a safe, redacted form.

Deadline tests fail intermittently -> Stop using real sleeps and wall-clock timing in unit tests. Inject a clock as shown above. Test real cancellation behavior in a smaller integration suite with generous environmental tolerance.

The scripted model runs out of decisions -> The loop made more calls than the scenario expected, which is usually useful evidence. Inspect the trace and counters before adding another scripted response. Do not hide an unintended call by making the fake repeat its last decision forever.

A max-iteration exit is reported as success -> Keep stopReason independent from any partial answer text. A partial draft may be useful for debugging, but it must not change a guardrail exit into completed.

Best Practices

  • Define success semantically. A nonempty answer is not proof that the requested task completed.
  • Keep independent hard guards for iteration count, elapsed time, and repeated consequential actions.
  • Assert the last allowed call and the first forbidden call at every boundary.
  • Make tools idempotent and attach stable operation IDs to side-effecting requests.
  • Emit one machine-readable stop event with run ID, reason, iteration, and sanitized context.
  • Separate deterministic orchestration tests from stochastic live-model evaluations.
  • Treat cancellation, process restart, and human approval as additional termination scenarios for production agents.

Loop termination is only one layer. Planning can fail while the loop exits cleanly, so use the step-by-step guide to testing AI agent planning failures for plan quality and recovery checks. If agents delegate work, verify that ownership and context survive transitions with the multi-agent handoff quality evaluation tutorial.

Broaden the suite with AI test automation best practices for risk-based coverage and API testing error-handling techniques for tool adapter failures. Those wider testing guides help you validate the components around the loop without weakening the deterministic termination checks.

Where To Go Next

Return to the complete AI agent testing guide and place these termination checks inside a broader test pyramid. Unit-test loop control on every commit, integration-test provider and tool cancellation, then run scenario evaluations before release.

Next, learn to measure AI agent task completion rate. Termination tells you that a run ended; task completion tells you whether it achieved the user's goal. Use both metrics because a quickly terminated failure is safe but not useful, while an apparent completion without verified outcomes is misleading.

Add production dashboards by stop reason. Alert on sudden increases in max_iterations, repeated_action, and deadline_exceeded, but inspect absolute volume and workflow changes before choosing thresholds. Sample sanitized traces for diagnosis and retain correlation IDs that connect model, tool, approval, and stop events.

Interview Questions and Answers

Q: Why is a maximum iteration count not enough to test agent termination?

It only bounds total turns. It does not prove semantic completion, detect repeated side effects early, enforce elapsed-time budgets, or distinguish refusal from failure. Use explicit stop reasons plus independent guards and assert each path.

Q: Why use a scripted model in loop-control tests?

A scripted model makes action sequences deterministic and replayable. It lets you test orchestration boundaries without network latency or sampling variance. Live-model tests remain valuable for decision quality, but they belong in a separate evaluation layer.

Q: What should a termination trace contain?

Record the run ID, iteration, decision category, tool result category, and final stop reason. Include timing and stable operation IDs where useful. Redact prompts, credentials, and sensitive tool payloads.

Q: Where should the deadline check occur?

Check before every new model iteration so no new work begins after the budget expires. Add per-operation cancellation separately for model or tool calls that might hang. Test the exact equality boundary with an injected clock.

Q: How do you test that no work occurs after termination?

Assert exact model and tool call counts, inspect the final trace event, and use strict fakes that fail on unexpected calls. For side-effecting integrations, also assert idempotency keys and downstream request counts.

Q: How should repeated actions be compared?

Build a canonical signature from the tool name and semantically meaningful arguments. Sort keys and remove volatile fields before hashing or comparing. Tune thresholds by tool risk and workflow expectations.

Conclusion

To test AI agent loop termination reliably, make every exit explicit, deterministic, and observable. Script the model decisions, assert the stop reason and boundary counts, inspect the trace, and prove that the first forbidden call never occurs.

Start with the five exits in this tutorial, then add cancellation, approval denial, invalid model output, and recovery behavior from your real architecture. The result is an agent that not only produces answers, but also stops predictably when success, safety, or resource limits require it.

Interview Questions and Answers

Why is loop termination a first-class test concern for AI agents?

Agents can repeatedly call models and tools, so a failure to stop can increase cost, latency, and side effects. I expose a typed stop reason, counters, and a trace. Tests then prove both the expected outcome and the absence of work after termination.

How would you test the maximum iteration boundary?

I script more nonterminal decisions than the configured limit and set the cap to a small value. I assert exactly that many model and allowed tool calls, a `max_iterations` result, and no request beyond the cap. I also document whether the cap counts model decisions or completed tool cycles.

How do deterministic and live-model tests differ?

Deterministic tests validate orchestration, boundaries, and state transitions with scripted decisions. Live-model evaluations validate decision quality and robustness across realistic prompts. Keeping them separate makes failures diagnosable while preserving coverage of probabilistic behavior.

How would you detect a repeated tool-call loop?

I canonicalize the tool name and meaningful arguments, then count consecutive or recent matching signatures. The loop stops before the threshold-triggering side effect executes. Thresholds vary by tool risk, and consequential tools still require idempotency.

What is the difference between a loop deadline and an operation timeout?

A loop deadline prevents another iteration from starting after the total budget expires. An operation timeout cancels one model or tool request that takes too long. A robust agent needs both because the outer loop cannot progress while awaiting a hung operation.

What observability would you add for termination?

I emit one structured stop event with run ID, reason, iteration, elapsed time, and safe counters. I connect it to sanitized decision and tool events through correlation IDs. Dashboards group rates by workflow and stop reason so regressions are visible.

Frequently Asked Questions

How do you test AI agent loop termination?

Feed the loop deterministic model decisions for every exit path, then assert the stop reason, iteration count, model calls, tool calls, and final trace event. Also prove that no call occurs after the terminal decision or guard boundary.

What termination conditions should an AI agent have?

At minimum, support successful completion, refusal, a hard iteration limit, a deadline, and protection against repeated actions. Production agents may also need cancellation, approval denial, budget exhaustion, and invalid-output exits.

How can I prevent an AI agent infinite loop?

Set a hard iteration cap and deadline, detect repeated canonical actions, and bound retries inside every model and tool adapter. Make consequential tools idempotent so an orchestration defect cannot duplicate side effects.

Should agent termination tests call a live LLM?

Not for core loop-control unit tests. Use a scripted model for deterministic sequences, then add a smaller integration suite for provider parsing, cancellation, and real-model behavior.

What is a good maximum iteration limit for an AI agent?

There is no universal number. Choose the limit from the workflow's expected useful steps, tool risk, latency budget, and cost budget, then test the exact boundary.

How do you test an agent timeout without flaky sleeps?

Inject a clock into the loop and advance it deterministically from the test. Test operation cancellation separately with an abort-aware adapter because a loop deadline cannot interrupt a promise that never settles.

Related Guides