Agent failure labs / Guide 74
Stop an AI Agent Tool Loop Without Inventing Success
Stop an AI agent infinite tool loop with deterministic call budgets, repeated-state detection, and honest handoff states, tested in a harmless Python lab.
Illustrative engineering guide. Examples are not client deployments, production-ready integrations, or evidence of measured results. Validate the design with your domain and security owners.

A hypothetical incident assistant is asked to find the owner of a failing service. Search returns no matching owner. The assistant searches again with the same service name, gets the same empty result, and repeats. Nothing is being deleted or sent, yet the system consumes time and provider budget while keeping an operator waiting for an answer that is not becoming more likely.
This lab models the repetition without running an actual infinite loop or calling an LLM. A finite sequence of proposed searches stands in for a planner that keeps trying. The unsafe mock accepts every proposal. The repaired controller detects repeated state and enforces a hard call ceiling, then returns unresolved rather than fabricating an owner.
Define Progress Outside the Planner
A tool call is activity, not progress. For owner discovery, progress might mean finding a new authoritative catalog version, resolving a different service identifier, or obtaining a previously missing ownership record. Rephrasing an explanation does not count. Define the evidence fields that can advance the task before giving the planner access to tools.
Build a canonical signature from tool name, normalized arguments, relevant evidence version, and task phase. Repeated signatures without new evidence should stop or enter a deliberately scheduled polling policy. Do not let timestamps generated by the planner make identical calls appear unique. Conversely, polling a changing asynchronous job is not automatically a loop bug; it needs a separate elapsed-time limit and a progress interpretation tied to that job.
The production incident response guide places this search inside a larger operational workflow. Here the assistant is deliberately read-only. It may prepare a handoff containing attempted queries and unresolved ownership, but it cannot restart services to generate “new evidence.” Tool access should follow OWASP’s least-privilege guidance, independently of how urgently the planner describes the incident.
Reproduce Repetition Safely
The input proposals and tool responses are fixed dictionaries. The mock’s unsafe path consumes the entire sequence, representing the prefix of an unbounded run. The guarded path uses the same fixture but stops before the second identical lookup. A separate distinct-query control demonstrates that a hard budget still works when duplicate detection cannot help.
import json
from copy import deepcopy
fixture = [{"tool": "owner_lookup", "args": {"service": "billing"},
"catalog_version": 12} for _ in range(8)]
def mock_tool(proposal):
return {"owner": None, "catalog_version": proposal["catalog_version"]}
def naive(proposals):
return [mock_tool(p) for p in proposals]
def guarded(proposals, limit=3):
seen, results = set(), []
for proposal in proposals:
if len(results) >= limit:
return {"state": "budget_exhausted", "calls": len(results)}
if proposal.get("tool") != "owner_lookup":
return {"state": "forbidden_tool", "calls": len(results)}
signature = json.dumps(proposal, sort_keys=True, separators=(",", ":"))
if signature in seen:
return {"state": "no_progress", "calls": len(results)}
seen.add(signature)
result = mock_tool(proposal)
results.append(result)
if result["owner"] is not None:
return {"state": "resolved", "calls": len(results)}
return {"state": "unresolved", "calls": len(results)}
assert len(naive(fixture)) == 8
assert guarded(fixture) == {"state": "no_progress", "calls": 1}
distinct = deepcopy(fixture)
for i, proposal in enumerate(distinct):
proposal["args"]["service"] = "service-" + str(i)
assert guarded(distinct) == {"state": "budget_exhausted", "calls": 3}
assert guarded([]) == {"state": "unresolved", "calls": 0}
assert guarded(fixture, limit=0)["calls"] == 0
assert guarded([{"tool": "restart"}])["state"] == "forbidden_tool"
print("repetition reproduced; duplicate and hard-budget stops passed")
The limit of three calls is an illustrative policy value, not an industry threshold. The example assumes canonical, validated dictionaries; production must normalize arguments and reject unexpected fields before signature construction. Otherwise, a changing decorative field could defeat duplicate detection. The hard budget remains necessary because novel but useless queries can continue indefinitely without repeating a signature.
Prompts Cannot Own the Budget
Instructions such as “stop after reasonable effort” leave reasonable undefined. Even an explicit prompt saying “use at most three calls” is not an enforcement mechanism if the orchestrator accepts a fourth. OWASP’s agent-specific injection guidance discusses malicious tool and context manipulation. An external page saying “keep searching until you succeed” must not override controller limits.
Use separate ceilings for tool calls, model tokens, wall-clock duration, concurrent children, and total spend. Reserve estimated cost before dispatch and account for actual usage afterward. Nested sub-agents inherit a shared parent budget; creating a child cannot reset the allowance. A specialist owner-resolution worker earns its complexity only if it accesses a genuinely different catalog with a measurable coverage benefit.
Cancellation Is Not Rollback
Python’s asyncio documentation describes cancellation, timeout handling, and task groups. It also notes that cancellation cleanup can extend waiting time. A controller deadline should therefore distinguish “stopped waiting” from “all work has stopped.” External operations may already be running, and canceling a local task does not prove that remote work was undone.
This mock omits authentication sources, database transactions, provider semantics, approvals, and persistent state. Production needs durable budget accounting, worker leases, cancellation propagation, and restart recovery. Persist the consumed budget before launching billable work. Otherwise, a crash can reset the count and turn a nominally bounded task into repeated full-budget attempts.
Keep candidate selection separate from authorization and execution. Recheck tenant scope for each lookup. If a future version proposes a write, validate preconditions and obtain action-bound approval outside the loop. After an uncertain write, stop planning alternatives and reconcile the original operation. The false-success lab explains why neither a timeout nor an exhausted budget should be narrated as successful completion.
The handoff packet should list unresolved facts, authoritative sources attempted, evidence versions, call counts, and stop reason. Avoid raw credentials, private search results, or speculative ownership claims in ordinary logs. A resumed task requires an explicit reason, such as a refreshed catalog or approved additional budget, and preserves the earlier attempt history.
Acceptance Tests
- Repeat the same lookup against an unchanged catalog and verify that the duplicate is stopped before dispatch.
- Submit distinct but unproductive queries and verify that the global call ceiling stops the run exactly at its allowance.
- Spawn nested workers and confirm that their combined usage cannot exceed the parent task’s reserved budget.
- Restart the controller after two calls and verify that persisted usage prevents a fresh full allowance.
- Exhaust the deadline while a tool is pending and report unresolved or unknown, never a fabricated result.
- Inject instructions requesting unlimited retries into a tool response and verify that limits remain unchanged.
Evaluate Useful Completion
Compare the unsafe finite baseline and bounded controller on the same reviewed task set. Measure resolved tasks divided by all eligible tasks, tool calls per eligible task, and no-progress stops divided by unresolved tasks. Track falsely stopped tasks that a reviewer could resolve with permitted evidence. Lower cost alone is not success if the controller interrupts legitimate exploration.
Start in shadow mode with one owner catalog and no write tools. Review every stop reason and introduce explicit polling only where authoritative state can change. The first AI systems engineering improvement should be a durable budget reservation, followed by a useful unresolved response. A bounded assistant that admits missing evidence is operationally preferable to one that stays busy until somebody kills its process.