Context and Tools
Agents and Tool Use

Preventing Infinite Loops, Duplicate Actions, and Unsafe Tool Calls

Preventing Infinite Loops, Duplicate Actions, and Unsafe Tool Calls
This post explores how to prevent AI agents from running away by implementing three essential guards: hard limits on infinite loops, detection and rejection of duplicate actions, and strict enforcement of a tool allow-list. Through practical Python examples, it provides a reliable pattern for securing agentic loops before connecting them to real-world production tools

The first agent I put into a real workflow had three separate ways of running away from me. It once called the same search tool fourteen times because each result felt “incomplete.” Another time it requested a database write, received a success response, and then requested the exact same write again. A third time it tried to call a tool that did not exist in the allow-list and kept inventing slightly different names in the hope that one of them would work.

None of those failures were mysterious once I looked at the logs. They were all the natural result of giving a language model a loop and no hard boundaries. This post is about adding those boundaries. We will take the agent we have been building—complete with the four-stage loop and the human approval gate—and add three concrete guards:

  1. A hard stop on infinite or runaway loops

  2. Detection and rejection of duplicate actions

  3. Strict enforcement of the tool allow-list so unsafe or unknown calls never execute

The goal is still the same:

Build the smallest useful version, then test what breaks.

Why These Three Guards Belong Together

An agent that can loop forever, repeat side effects, or invent new tools is not merely unreliable; it is unsafe. The three problems interact:

  • An infinite loop often produces duplicate actions.

  • Duplicate actions are especially dangerous when the tool has side effects.

  • An unknown tool is the easiest path for a confused or injected prompt to escape the design you intended.

Fixing only one of them leaves the other two open. We will close all three with the smallest amount of code that actually works.

Guard 1: Hard Limits on the Loop

A close-up documentary photo of a laptop screen displaying terminal debugging logs and code counters for infinite loop limits.

We already had a max_iterations parameter. That is necessary but not sufficient. A more complete set of limits looks like this:

Python

MAX_ITERATIONS = 8
MAX_TOOL_CALLS = 12          # total tool calls across the whole run
MAX_IDENTICAL_CALLS = 2      # same tool + same arguments

Inside the loop we maintain simple counters:

Python

iteration = 0
total_tool_calls = 0
recent_calls = []            # list of (tool_name, canonical_input) tuples

Before every new model call we check the counters. If any limit is exceeded we stop, return a clear message to the caller, and log the full history. The model never gets a chance to continue.

I prefer hard numeric limits over clever heuristics. Numeric limits are easy to test, easy to explain to a teammate, and easy to audit later.

Guard 2: Detecting Duplicate Actions

A duplicate is defined as the same tool name with the same arguments appearing more than MAX_IDENTICAL_CALLS times in the current run. To make comparison reliable we canonicalize the input:

Python

import json

def canonical_input(tool_input: dict) -> str:
    """Stable string for comparison, independent of key order."""
    return json.dumps(tool_input, sort_keys=True, separators=(",", ":"))

When a tool is requested we check the recent history:

Python

def is_duplicate(tool_name: str, tool_input: dict, recent_calls: list) -> bool:
    key = (tool_name, canonical_input(tool_input))
    count = sum(1 for call in recent_calls if call == key)
    return count >= MAX_IDENTICAL_CALLS

If the call is a duplicate we do not execute the tool. Instead we return a tool result that tells the model the action was rejected for repetition:

text

"Rejected: this exact tool call has already been made the maximum number of times in this run."

The model then has a chance to change its plan. Most of the time it does. When it does not, the outer iteration limit still protects us.

Guard 3: Strict Tool Allow-List

A documentary photograph of two developers collaborating on code review at a desk, examining tool security allow-lists.

The agent should only ever be able to call tools that appear in both the TOOLS list sent to the model and the TOOL_MAP used for execution. Any other name is treated as unsafe:

Python

def is_allowed_tool(tool_name: str) -> bool:
    return tool_name in TOOL_MAP

If the model invents a name, we never call TOOL_MAP[tool_name]. We return an explicit error result and continue. Logging the invented name is useful; it often reveals prompt-injection attempts or model confusion about the available tools.

In practice I also keep the SIDE_EFFECT_TOOLS set from the previous post. Even an allowed tool still has to pass the human approval gate if it is marked as side-effecting.

Putting the Three Guards into the Loop

Here is the Call stage with all three guards applied. The rest of the agent remains the same as in the previous article.

Python

def run_agent(user_message: str) -> str:
    messages = [{"role": "user", "content": user_message}]
    recent_calls = []
    total_tool_calls = 0

    for iteration in range(1, MAX_ITERATIONS + 1):
        print(f"\n=== Iteration {iteration} ===")

        # Stage 1: Decide
        response = client.messages.create(
            model="claude-sonnet-5",  # verify current ID in official docs
            max_tokens=512,
            tools=TOOLS,
            messages=messages
        )
        messages.append({"role": "assistant", "content": response.content})
        print(f"Stop reason: {response.stop_reason}")

        if response.stop_reason != "tool_use":
            return "".join(
                block.text for block in response.content if block.type == "text"
            )

        # Stage 2: Call (with all three guards)
        tool_results = []
        for block in response.content:
            if block.type != "tool_use":
                continue

            tool_name = block.name
            tool_input = block.input
            tool_use_id = block.id
            total_tool_calls += 1

            print(f"Requested: {tool_name}({tool_input})")

            # Guard 3: unknown tool
            if not is_allowed_tool(tool_name):
                result = f"Rejected: tool '{tool_name}' is not in the allow-list."
                print(result)
                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": tool_use_id,
                    "content": result
                })
                continue

            # Guard 2: duplicate action
            if is_duplicate(tool_name, tool_input, recent_calls):
                result = (
                    "Rejected: this exact tool call has already been made "
                    "the maximum number of times in this run."
                )
                print(result)
                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": tool_use_id,
                    "content": result
                })
                continue

            # Guard 1 (partial): total tool-call budget
            if total_tool_calls > MAX_TOOL_CALLS:
                result = "Rejected: maximum total tool calls for this run exceeded."
                print(result)
                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": tool_use_id,
                    "content": result
                })
                continue

            # Human approval for side-effecting tools (from previous post)
            if tool_name in SIDE_EFFECT_TOOLS:
                if not ask_for_approval(tool_name, tool_input):
                    result = f"Action denied by human reviewer. Tool '{tool_name}' was not executed."
                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": tool_use_id,
                        "content": result
                    })
                    continue

            # Execute
            try:
                result = TOOL_MAP[tool_name](**tool_input)
            except Exception as e:
                result = f"Error executing tool: {type(e).__name__}: {e}"

            # Record the call so future duplicates can be detected
            recent_calls.append((tool_name, canonical_input(tool_input)))
            print(f"Result: {result}")
            tool_results.append({
                "type": "tool_result",
                "tool_use_id": tool_use_id,
                "content": result
            })

        # Stage 3: Observe
        messages.append({"role": "user", "content": tool_results})

    return "Stopped: max iterations reached."

The three guards sit in front of the actual execution. Nothing reaches the real tool function unless it has passed every check.

Three Concrete Tests You Should Run

Test 1: Force an Infinite Loop Tendency

Ask the agent a deliberately open-ended research question that tempts repeated searching. Confirm that the iteration limit or the total-tool-call limit fires and the agent stops with a clear message.

Test 2: Force a Duplicate

Craft a user request that causes the model to call the same tool with the same arguments twice. Confirm the second call is rejected with the duplicate message and that the model receives that rejection as a tool result.

Test 3: Force an Unknown Tool

Temporarily add a sentence to the system prompt that encourages the model to call a tool named secret_override. Confirm the call is rejected by the allow-list and never reaches any real implementation.

I run these three tests every time I change the loop or add a new tool. They take only a few minutes and have caught several regressions before they reached a client project.

Practical Rules That Keep the Guards Effective

  • Prefer hard numeric limits over soft “the model will stop when it is finished” assumptions.

  • Canonicalize tool inputs before comparing them; otherwise two calls that differ only in key order look different.

  • Log every rejection with the reason (unknown tool, duplicate, budget exceeded, human denial). The logs become the audit trail.

  • Keep the allow-list and the SIDE_EFFECT_TOOLS set in the same file so they stay visible and reviewable.

  • When a guard fires, give the model a clear, specific tool result. Vague errors encourage retries.

What These Guards Do Not Solve

They do not:

  • Replace proper authentication or authorization on the tools themselves

  • Defend against a compromised human who approves a dangerous action

  • Automatically summarize or compress a growing context window

  • Guarantee that a tool implementation is free of its own bugs

Those concerns belong to later production and security posts. The guards we added today simply make the agent loop much harder to push into a runaway or unsafe state.

Verification Checklist

  1. Run a normal successful request and confirm none of the guards fire.

  2. Force repeated identical tool calls and confirm the duplicate guard rejects the extras.

  3. Exceed the iteration or total-tool-call limit and confirm the agent stops cleanly.

  4. Request a tool name that is not in TOOL_MAP and confirm it is rejected.

  5. Inspect the logs and confirm every rejection is recorded with a clear reason.

If all five steps pass, the three guards are doing their job.

Update Note

The Messages API tool-use shapes remain stable. Model identifiers change; always verify the current recommended ID in the official Anthropic documentation. Newer SDK helpers may offer higher-level loop control, but the underlying need for hard limits, duplicate detection, and an explicit allow-list does not disappear. Implement those controls yourself even when you later adopt convenience wrappers.

Looking Ahead

We have now completed the first four agent posts: a minimal tool-using agent, a clear picture of the decide-call-observe-respond loop, a human approval gate, and concrete guards against the most common runaway behaviors. The next set of posts moves into Production Workflows—document pipelines, triage systems, and internal research assistants that sit on top of this foundation.

Keep the guarded agent. When you later connect it to real email, real databases, or real customer data, you will already have the boundaries that keep a single confused turn from becoming an incident.

Build the smallest useful version, then test what breaks.

— Caleb Fletcher

Boise, Idaho

Updated · 2026-10-16 11:26
Feedback

No feedback yet — submit the first.

Submit feedback
© 2026 Context & Tools. All rights reserved. data-driven, published weekly ♥