Loop Guard

Always-on idempotency-aware safety net for tool execution loops

Loop Guard stops broken or misconfigured tools from burning tokens by counting per-turn calls and reacting differently to safe-to-repeat vs state-changing tools.

python
from praisonaiagents import Agent

agent = Agent(
    name="Safe Coder",
    instructions="Use tools carefully.",
)
agent.start("Fix the failing unit test in src/utils.py")

The user sends a task; Loop Guard tracks tool calls per turn and blocks runaway loops automatically.

Diagram

Loop Guard

Idempotent
read/search/list

Mutating
write/exec/delete

Under

Warn

Block

Halt

🛠️ Tool Call

🔍 Classify

📈 5 / 8 / 12

📉 3 / 5 / 7

Action?

✅ Allow

⚠️ Warn

🛑 Block

🔥 Halt

Quick Start

  1. It's already on
    python
    from praisonaiagents import Agent
    
    agent = Agent(
        instructions="You are a helpful assistant."
    )
    
    # Loop Guard is initialised automatically.
    # No flag needed — every Agent gets one.
    agent.start("Summarise the README")
  2. Tune the thresholds

    For power users, you can customize thresholds after agent creation:

    python
    from praisonaiagents import Agent
    from praisonaiagents.escalation.loop_guard import LoopGuard, LoopGuardConfig
    
    agent = Agent(instructions="Polls a slow status endpoint")
    
    agent._loop_guard = LoopGuard(LoopGuardConfig(
        idempotent_warn_threshold=10,
        idempotent_block_threshold=20,
        idempotent_halt_threshold=30,
        max_time_per_turn=300.0,
    ))

    Per-turn guards reuse this guard’s config (created lazily on the turn’s first tool call), so one agent._loop_guard = ... reconfiguration applies to every subsequent turn, streaming or non-streaming, sync or async.

  3. Turn it off
    python
    from praisonaiagents import Agent
    from praisonaiagents.escalation.loop_guard import LoopGuard, LoopGuardConfig
    
    agent = Agent(instructions="Agent without safety guard")
    
    agent._loop_guard = LoopGuard(LoopGuardConfig(enabled=False))

How It Works

Diagram
ToolLoopGuardAgentUserToolLoopGuardAgentUseralt[ALLOW or WARN][BLOCK][HALT]Requestcheck() (pre-execution)Decision (ALLOW/WARN/BLOCK/HALT)ExecuteResultrecord() (post-execution)check() (post-execution){“error”: “[loop-guard] …”, “loop_blocked”: True}ToolExecutionError (not retryable)
DecisionEffectUser sees
ALLOWTool runs normallyNormal result
WARNTool runs; warning loggedResult + _loop_guard warning metadata
BLOCKTool execution replaced{"error": "[loop-guard] ...", "loop_blocked": True}
HALTNon-retryable exception raisedToolExecutionError propagates out

check() is bracketed by a reset_turn() at _chat_impl / _achat_impl entry, so every turn starts with fresh counts and a fresh time budget. Overlapping concurrent turns on the same Agent each get their own LoopGuard (same config, independent counters), so one caller’s traffic can no longer exhaust another caller’s per-turn limits. agent.execute_tool(...) called outside a chat turn still uses the agent-level guard. Agents with no tools never get a Loop Guard force-created.


User Interaction Flow

Here’s what happens when an agent repeatedly calls the same tool:

Diagram

Results changing
progressing

Same result each time
stuck

Yes

Yes

Agent calls a tool repeatedly

What are the
results doing?

✅ ALLOW every call
Even past the count threshold,
BLOCK/HALT is downgraded to WARN

Streak grows

Streak reaches
no_progress_warn?

⚠️ WARN attached

Streak reaches
no_progress_halt?

🔥 HALT — ToolExecutionError

Two signals now drive the outcome: how many times the tool has been called this turn, and whether the results are still changing. A busy-but-progressing tool sees at most a WARN; a stuck-with-identical-results tool escalates through WARN to HALT.


Tool Classification

Loop Guard categorizes tools into two buckets with different thresholds:

Diagram

No

Yes

Unsure

Yes

No

New custom tool

Does it change
state on disk,
DB, or remote?

✅ Idempotent bucket
5 / 8 / 12 per turn

🛑 Mutating bucket
3 / 5 / 7 per turn

Tool name contains
read/get/list/search/
find/show/view/check?

Idempotent tools (safe to repeat): read_file, list_files, search_files, web_search, get_memory, git_status, git_log, db_query, etc.

Mutating tools (state-changing): write_file, edit_file, delete_file, execute_code, shell, git_commit, git_push, sql_insert, install_package, etc.


Configuration Options

OptionTypeDefaultDescription
enabledboolTrueEnable/disable Loop Guard entirely
idempotent_warn_thresholdint5Warning threshold for idempotent tools
idempotent_block_thresholdint8Block threshold for idempotent tools
idempotent_halt_thresholdint12Halt threshold for idempotent tools
mutating_warn_thresholdint3Warning threshold for mutating tools
mutating_block_thresholdint5Block threshold for mutating tools
mutating_halt_thresholdint7Halt threshold for mutating tools
max_time_per_turnfloat120.0Maximum seconds per turn before timeout
no_progress_warnint4Warn once the trailing run of identical tool-result fingerprints reaches this length. Changing results reset the streak.
no_progress_haltint8Halt once the trailing run of identical tool-result fingerprints reaches this length. Changing results reset the streak.

GuardAction values: ALLOW, WARN, BLOCK, HALT

LoopGuardDecision fields: action, code, message, metadata


No-Progress Detection

No-progress detection watches for identical repeated results, not raw tool-call counts.

Diagram

No — result changed

Yes

No

Yes

No

Yes

🛠️ Tool call recorded

🔑 Fingerprint = tool + args + result-hash

Same as previous
tool + result-hash?

✅ Streak resets
Progress detected

📊 Streak + 1

Streak ≥
no_progress_warn?

⏭️ Continue

⚠️ WARN

Streak ≥
no_progress_halt?

🔥 HALT

What counts as progress (resets the streak):

  • The result changes for the same tool + same args (e.g. check_status(job=42) returning "IN_PROGRESS" then "COMPLETE").
  • A different tool is called (streaks require the same tool, not just the same result value).
  • agent._loop_guard.mark_progress("...") is called — marks the current position so only later tool calls can contribute to a future streak.

What does NOT reset the streak:

  • The same tool returning the same result over and over — that is the “stuck” pattern the guard exists to catch.
  • Falsy repeated results ("", [], {}, 0, False) — these are wrapped internally so they still fingerprint, closing a loophole that used to disable no-progress detection.

Count thresholds are also stuck-aware. The idempotent (5 / 8 / 12) and mutating (3 / 5 / 7) thresholds still count tool calls, but a BLOCK or HALT from those thresholds is downgraded to a WARN when the trailing identical-result run is below no_progress_warn. So a legitimately busy but progressing tool (many calls, changing output) will not be blocked purely on frequency.

Example: polling that used to be halted, now allowed

python
from praisonaiagents import Agent

agent = Agent(
    name="Deployment Watcher",
    instructions="Poll the deployment status until it finishes.",
)

# check_deploy(job='X') returns 'IN_PROGRESS', 'IN_PROGRESS', 'RUNNING', 'COMPLETE'.
# Each transition changes the result-hash, so the no-progress streak stays at 1.
agent.start("Watch deploy-42 until it is done.")

Example: genuine stall, still halted

python
from praisonaiagents import Agent

agent = Agent(
    name="Config Reader",
    instructions="Read a config file that never appears.",
)

# read_file('missing.yaml') returns the same error result every time.
# The streak grows to no_progress_halt=8 and the tool is halted with
# ToolExecutionError.
agent.start("Read missing.yaml.")

Manually marking progress

If your agent completes a logical sub-step (a checkpoint, a milestone), call mark_progress() so any future no-progress streak starts fresh from that point:

python
from praisonaiagents import Agent

agent = Agent(instructions="Multi-step data pipeline")

# ... run some tools ...
agent._loop_guard.mark_progress("stage-1-complete")
# Only tool calls made AFTER this marker can contribute to a future streak.

What happens at each threshold

DecisionPre-execution effectPost-execution effectUser sees
ALLOWTool runs normallyCounter incrementedNormal result
WARNTool runs; warning loggedWarning appended/attached to result_loop_guard key in dict result, or [loop-guard] ... suffix on string result
BLOCKTool not executedCounter still incrementedTool result replaced with {"error": "[loop-guard] ...", "loop_blocked": True}
HALTToolExecutionError raised, is_retryable=FalseN/AException propagates out of agent.chat(), agent.start(), agent.achat(), agent.astart(), or the async workflow that scheduled the tool

Sync and Async coverage

Loop Guard applies identically to both execution paths — sync (agent.start() / agent.chat()) and async (agent.achat() / async workflows).

Diagram
ToolLoopGuardAgentUserToolLoopGuardAgentUserstart / achat with a taskcheck(tool, args, pre=True)ALLOW / WARN / BLOCK / HALTrun (unless BLOCK/HALT)result or exceptionrecord(tool, args, success, result=…)check(tool, args, pre=False)post-execution decisionresult (with [loop-guard] annotation on WARN)

What triggers Loop Guard on the async path:

  • Repeated identical async tool calls whose results do not change (no_progress_* streak grows).
  • Repeated async tool timeouts — each asyncio.wait_for timeout is now recorded as a failure and counts toward the streak.
  • Repeated raised exceptions inside an async tool — each raised call is recorded as a failure the next pre-execution check can act on.
python
from praisonaiagents import Agent

agent = Agent(instructions="Poll a flaky endpoint.")

# Async path: identical failing calls escalate WARN → HALT and the run stops.
result = await agent.achat("Fetch /status until it returns COMPLETE.")

The sync and async paths share the same LoopGuard instance per Agent, so a mixed workload (sync then async on the same agent) accumulates counts against one turn’s thresholds.


Common Patterns

Async polling with changing results

Polling tools whose output actually transitions state (e.g. pending → running → done) do not need any config change — the no-progress guard sees each transition as progress and never trips. Only the count-based idempotent thresholds (5 / 8 / 12) could otherwise fire on high call volume, and those are now downgraded to WARN while results keep changing.

python
from praisonaiagents import Agent

agent = Agent(
    name="Job Poller",
    instructions="Call check_job_status until it returns COMPLETE.",
)

# Default LoopGuardConfig is fine — no tuning required.
agent.start("Wait for job-42 to complete.")

If your polling tool paces itself with a wait / sleep tool that returns the same value each call, wrap the pair (check_job_status + wait) with an explicit progress marker between transitions so wait‘s identical returns do not build a stuck streak:

python
from praisonaiagents import Agent

agent = Agent(instructions="Poll and pace")
# ... after each observed status change ...
agent._loop_guard.mark_progress("status-transition")

Polling a slow status endpoint

python
from praisonaiagents import Agent
from praisonaiagents.escalation.loop_guard import LoopGuard, LoopGuardConfig

agent = Agent(instructions="Monitor deployment status")

# Increase idempotent thresholds for polling scenarios
agent._loop_guard = LoopGuard(LoopGuardConfig(
    idempotent_warn_threshold=15,
    idempotent_block_threshold=25,
    idempotent_halt_threshold=40,
    max_time_per_turn=300.0,  # 5 minute timeout
))

Strict mode for production database agents

python
from praisonaiagents import Agent
from praisonaiagents.escalation.loop_guard import LoopGuard, LoopGuardConfig

agent = Agent(instructions="Manage user database")

# Lower mutating thresholds for safety
agent._loop_guard = LoopGuard(LoopGuardConfig(
    mutating_warn_threshold=2,
    mutating_block_threshold=3,
    mutating_halt_threshold=4,
))

Shared agent on a web server / gateway

python
import threading
from praisonaiagents import Agent

def write_file(path: str) -> str:
    """Write a file (distinct path each call)."""
    return f"wrote {path}"

# One Agent, reused across many overlapping requests
agent = Agent(name="svc", instructions="Write the requested file.", tools=[write_file])

def handle_request(i: int) -> str:
    # Each chat() call is one turn, and each turn gets its own LoopGuard
    return agent.chat(f"Write /tmp/out_{i}.txt")

threads = [threading.Thread(target=handle_request, args=(i,)) for i in range(20)]
for t in threads: t.start()
for t in threads: t.join()

Each chat() / achat() turn, and each streaming generator, binds its own LoopGuard on a ContextVar that is copied into tool worker threads — so twenty overlapping writes to twenty different paths neither warn nor HALT each other.

Long-lived agents

python
from praisonaiagents import Agent

def lookup_order(q: str) -> str:
    """Look up an order status."""
    return f"order-status-for-{q}"

agent = Agent(
    name="Support Bot",
    instructions="Answer order-status questions.",
    tools=[lookup_order],
)

# Each turn starts clean: no loop-guard warnings from earlier turns,
# and no Turn time limit error after minutes of agent life.
for i in range(16):
    print(agent.chat(f"q{i}"))

Observability with stats

python
agent = Agent(instructions="Data processing agent")

# Get statistics for dashboards
stats = agent._loop_guard.get_stats()
print(f"Turn elapsed: {stats['turn_elapsed']}s")
print(f"Tool counts: {stats['tool_counts']}")

stats["turn_elapsed"] is the current turn’s elapsed time, not an agent-lifetime value.


Relationship to Loop Detection

Loop Detection is also always-on as of PR #3005 — every Agent runs result-aware name + args + result-hash detection in the same tool-execution path. The two are complementary: Loop Guard counts per-turn tool calls with idempotent-vs-mutating thresholds, while Loop Detection catches repeated identical fingerprints at any frequency and back-fills result hashes so polling with progress is not flagged. Both fire through the same blocked_result path, so trace spans, stream events, and AFTER_TOOL hooks stay consistent regardless of which one triggers.


Best Practices

Long-lived agents are safe to reuse

Reuse one Agent across many turns of a bot, gateway, or chat UI. The per-turn tool counts and the 120 s max_time_per_turn budget reset at the start of every chat() / achat() call, so an idle session that wakes up 10 minutes later still gets a full fresh turn. Only the current turn’s work counts.

Trust the defaults

The default thresholds (5/8/12 for idempotent, 3/5/7 for mutating) work well for most agents. Only customize when you have specific use cases like status polling or strict production environments.

Tune thresholds, not the on/off switch

Resist the urge to disable Loop Guard entirely. Instead, adjust thresholds to match your agent’s workflow. A monitoring agent might need higher idempotent thresholds, but even monitoring agents can benefit from mutating tool limits.

Name custom tools so the heuristic works

If your custom tool isn’t in the explicit IDEMPOTENT_TOOLS or MUTATING_TOOLS sets, name it descriptively. Tools named check_status, read_config, or search_logs will be classified as idempotent automatically.

Use get_stats() for observability

Monitor your agents’ tool usage patterns with agent._loop_guard.get_stats(). High tool counts might indicate the agent is struggling with a task and needs different instructions or tools.

Reach for mark_progress() only at real checkpoints

No-progress detection is result-aware, so a tool that keeps producing new output is trusted even past the count thresholds. Reach for mark_progress() only when your workflow has a natural checkpoint (a sub-step completed, a milestone reached) that the result-hash on its own would not reflect.

Drop manual retry caps around async polling

Since PR #4005, agent.achat() and async workflows route tool calls through the same Loop Guard as agent.start(). If you were adding manual retry caps around async polling, you can drop them — the default idempotent thresholds (5 / 8 / 12) and the result-aware no-progress streak apply on both paths.


Related