| name | hermes-tool-loop-guard |
| description | Detect and stop within-turn tool-call failure loops and no-progress idempotent loops. Three detectors — exact failure (same tool+args), same-tool failure, no-progress (idempotent returns same result N times). Warn-first, hard-stop opt-in. Source: NousResearch/hermes-agent (MIT). |
Implementation (real, runnable — added 2026-06-19)
The original agent/tool_guardrails.py was already pure/side-effect-free
("the controller is intentionally side-effect free" — its own docstring),
so this is a near-verbatim port; only the two external imports
(utils.safe_json_loads, agent.tool_result_classification) were inlined.
- Module:
core/lib/hermes_adapted/tool_guardrails.py
- Tests:
tests/test_hermes_tool_guardrails.py (6 passing)
Note: core/skills/hermes-tool-guardrails/SKILL.md (a separate skill) is
NOT this — that skill describes a command-allowlist/approval-gate pattern
that doesn't match what's actually in hermes-agent's real
tool_guardrails.py. Fixed 2026-06-20: false source/license attribution
removed from that file (the pattern doesn't exist anywhere in the vendored
upstream source — verified by grep), content kept as Yana AI-native.
/hermes-tool-loop-guard
When to Use
- Agent loops calling the same tool with identical args and failing repeatedly
- Read-only tools (search, file read) returning identical results — agent stuck in no-progress loop
- Building a mission dispatcher or tool-execution layer that needs loop protection
- Autonomous agents that can run more than 10 tool calls per turn
Do NOT use for
- Command allowlist / approval gate — see [[hermes-tool-guardrails]] for that
- Human-in-the-loop flows where the user sees each call and interrupts manually
- See also: [[hermes-conversation-loop]] for iteration-budget and stale-stream detection
Three Detector Types
1. Exact failure — same tool + same args hash fails N times → warn / block
2. Same-tool failure — same tool name (any args) fails N times → warn / halt
3. No-progress — idempotent tool returns same result N times → warn / block
Idempotent vs Mutating classification
IDEMPOTENT_TOOLS = {
"read_file", "list_dir", "web_search", "web_fetch",
"browser_snapshot", "grep", "glob", "get_file_info",
"search_files", "view_image", "memory_recall", "get_env",
}
Implementation
import hashlib, json
from dataclasses import dataclass
from typing import Literal
Action = Literal["allow", "warn", "block", "halt"]
@dataclass
class LoopGuardConfig:
warnings_enabled: bool = True
hard_stop_enabled: bool = False
exact_warn_after: int = 2
exact_block_after: int = 5
tool_warn_after: int = 3
tool_halt_after: int = 8
noprog_warn_after: int = 2
noprog_block_after: int = 5
@dataclass
class LoopGuardDecision:
action: Action
message: str = ""
def _hash_args(args: dict) -> str:
return hashlib.sha256(
json.dumps(args, sort_keys=True, ensure_ascii=False).encode()
).hexdigest()[:16]
class ToolLoopGuard:
def __init__(self, cfg: LoopGuardConfig | = ):
._cfg = cfg LoopGuardConfig()
._exact_fails: [, ] = {}
._tool_fails: [, ] = {}
._noprog: [, [, ]] = {}
() -> LoopGuardDecision:
key =
(._exact_fails.get(key, ) >= ._cfg.exact_block_after
._cfg.hard_stop_enabled):
n = ._exact_fails[key]
LoopGuardDecision(,
)
(._tool_fails.get(tool, ) >= ._cfg.tool_halt_after
._cfg.hard_stop_enabled):
n = ._tool_fails[tool]
LoopGuardDecision(, )
LoopGuardDecision()
() -> LoopGuardDecision:
key =
success:
._exact_fails[key] = ._exact_fails.get(key, ) +
._tool_fails[tool] = ._tool_fails.get(tool, ) +
n = ._exact_fails[key]
n >= ._cfg.exact_warn_after ._cfg.warnings_enabled:
LoopGuardDecision(,
)
LoopGuardDecision()
tool IDEMPOTENT_TOOLS:
rh = hashlib.sha256(result.encode()).hexdigest()[:]
prev_n, prev_h = ._noprog.get(key, (, ))
rh == prev_h:
new_n = prev_n +
._noprog[key] = (new_n, rh)
new_n >= ._cfg.noprog_block_after ._cfg.hard_stop_enabled:
LoopGuardDecision(,
)
new_n >= ._cfg.noprog_warn_after ._cfg.warnings_enabled:
LoopGuardDecision(,
)
:
._noprog[key] = (, rh)
LoopGuardDecision()
() -> :
._exact_fails.clear()
._tool_fails.clear()
._noprog.clear()
Wiring into tool dispatch
guard = ToolLoopGuard(LoopGuardConfig(hard_stop_enabled=True))
guard.reset()
async def dispatch_tool(tool: str, args: dict) -> str:
pre = guard.before_call(tool, args)
if pre.action in ("block", "halt"):
return f"[LOOP-GUARD] {pre.message}"
if pre.action == "warn":
print(f"[WARN] {pre.message}")
try:
result, success = await run_tool(tool, args), True
except Exception as e:
result, success = str(e), False
post = guard.after_call(tool, args, result, success)
if post.action == "warn":
print(f"[WARN] {post.message}")
if post.action in ("block", "halt") and guard._cfg.hard_stop_enabled:
return f"[LOOP-GUARD] {post.message}"
return result
Anti-Fake-Pass Checklist
❌ reset() called per-call instead of per-turn — counts never accumulate
❌ Mutating tools in IDEMPOTENT_TOOLS — write_file returning same result is normal
❌ hard_stop_enabled=True in interactive mode — blocks user without escape
❌ Arg hash on unsorted dict — same args hash differently each call
❌ Result hash on truncated output — large identical results bypass dedup