fix(guardrails): preserve interactive platform defaults

This commit is contained in:
João Vitor Cunha
2026-08-28 15:17:00 -03:00
committed by Teknium
parent ee2147f9e6
commit 384fc4bf83
8 changed files with 38 additions and 17 deletions

View File

@@ -110,7 +110,7 @@ class ToolCallGuardrailConfig:
"""Thresholds for per-turn tool-call loop detection.
Warnings are enabled by default and never prevent tool execution. Hard stops
stay opt-in for interactive CLI/TUI sessions, but default on for
stay opt-in for interactive CLI/TUI/Desktop/ACP sessions, but default on for
non-interactive gateway/cron platforms where nobody is present to interrupt
a model that ignores loop warnings.
"""
@@ -129,7 +129,12 @@ class ToolCallGuardrailConfig:
loop_caps: "LoopCapConfig" = field(default_factory=lambda: LoopCapConfig())
@classmethod
def from_mapping(cls, data: Mapping[str, Any] | None, *, platform: str | None = None) -> "ToolCallGuardrailConfig":
def from_mapping(
cls,
data: Mapping[str, Any] | None,
*,
platform: str | None = None,
) -> "ToolCallGuardrailConfig":
"""Build config from the `tool_loop_guardrails` config.yaml section."""
if not isinstance(data, Mapping):
data = {}
@@ -229,11 +234,14 @@ class LoopCapConfig:
)
_INTERACTIVE_PLATFORMS = frozenset({"cli", "tui", "desktop", "acp"})
def _is_non_interactive_platform(platform: str | None) -> bool:
"""Return true for gateway/cron sessions where tool loops are unattended."""
if not isinstance(platform, str) or not platform.strip():
return False
return platform.strip().lower() not in {"cli", "tui"}
return platform.strip().lower() not in _INTERACTIVE_PLATFORMS
@dataclass(frozen=True)

View File

@@ -534,7 +534,7 @@ browser:
# =============================================================================
# Soft warnings are enabled by default. They append guidance to repeated failed
# or non-progressing tool results but still let the tool execute. Hard stops stay
# opt-in for interactive CLI/TUI sessions, but default on for non-interactive
# opt-in for interactive CLI/TUI/Desktop/ACP sessions, but default on for unattended
# gateway/cron sessions where nobody is present to interrupt a model that
# ignores loop warnings.
tool_loop_guardrails:

View File

@@ -1100,7 +1100,6 @@ def _ensure_hermes_home_managed(home: Path):
from hermes_cli.config_defaults import DEFAULT_CONFIG, OPTIONAL_ENV_VARS # noqa: F401
# =============================================================================
# Config Migration System
# =============================================================================

View File

@@ -320,7 +320,6 @@ LEGACY_AUTHOR_MAP = {
"pinkiilqwq@users.noreply.github.com": "PINKIIILQWQ", # PR #45035 salvage (resume-to-tip; #38763)
"pink@PinkdeMacBook-Air.local": "PINKIIILQWQ", # PR #45035 local git identity (resume-to-tip; #38763)
"ailang323@163.com": "ailang323", # PR #48682 salvage (compression-tip predicate; #38763)
"59806492+sitkarev@users.noreply.github.com": "sitkarev",
"zheng@omegasys.eu": "omegazheng",
"220877172+james47kjv@users.noreply.github.com": "james47kjv",

View File

@@ -75,12 +75,15 @@ def test_config_parses_nested_warn_and_hard_stop_thresholds():
assert cfg.no_progress_block_after == 8
def test_gateway_platform_defaults_to_hard_stop_without_changing_cli_default():
cli_cfg = ToolCallGuardrailConfig.from_mapping({}, platform="cli")
def test_gateway_platform_defaults_to_hard_stop_without_changing_interactive_defaults():
interactive_configs = [
ToolCallGuardrailConfig.from_mapping({}, platform=platform)
for platform in ("cli", "tui", "desktop", "acp")
]
telegram_cfg = ToolCallGuardrailConfig.from_mapping({}, platform="telegram")
cron_cfg = ToolCallGuardrailConfig.from_mapping({}, platform="cron")
assert cli_cfg.hard_stop_enabled is False
assert all(cfg.hard_stop_enabled is False for cfg in interactive_configs)
assert telegram_cfg.hard_stop_enabled is True
assert cron_cfg.hard_stop_enabled is True

View File

@@ -5,6 +5,8 @@ import uuid
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from run_agent import AIAgent
@@ -103,6 +105,18 @@ def test_gateway_platform_uses_hard_stop_default_without_cli_opt_in():
assert decision.code == "repeated_exact_failure_block"
@pytest.mark.parametrize("platform", ["desktop", "acp"])
def test_interactive_platforms_keep_warning_only_default(platform):
agent = _make_agent("web_search", platform=platform)
args = {"query": "same"}
_seed_exact_failures(agent, "web_search", args, count=5)
decision = getattr(agent, "_tool_guardrails").before_call("web_search", args)
assert decision.action == "allow"
assert decision.code == "allow"
def test_default_sequential_path_warns_repeated_exact_failure_without_blocking_execution():
agent = _make_agent("web_search")
args = {"query": "same"}

View File

@@ -1765,14 +1765,15 @@ The gate is independent of `tool_use_enforcement` — either can be on without t
## Tool-Loop Guardrails
Hermes detects when the agent is stuck in an unproductive tool-calling loop — the same tool call failing repeatedly, the same tool failing over and over, or an idempotent call returning the same result with no progress. By default it injects a **warning** into the tool result so the model self-corrects; it does not hard-stop, since a person watching the CLI/TUI can intervene.
Hermes detects when the agent is stuck in an unproductive tool-calling loop — the same tool call failing repeatedly, the same tool failing over and over, or an idempotent call returning the same result with no progress. By default it injects a **warning** into the tool result so the model self-corrects. Interactive CLI, TUI, Desktop, and ACP sessions remain warning-only because a person can intervene; unattended gateway and cron sessions enable hard stops by default.
For unattended gateway / server deployments, enable hard stops so a stuck agent is circuit-broken instead of burning the iteration budget:
The platform-aware default can be disabled for an unattended deployment, or hard stops can be explicitly enabled on every platform:
```yaml
tool_loop_guardrails:
warnings_enabled: true # inject warnings into tool results (default: true)
hard_stop_enabled: false # also BLOCK the call past the hard-stop threshold (default: false)
non_interactive_hard_stop_enabled: true # default hard stops for gateway/cron
warn_after:
exact_failure: 2 # identical failing call repeated N times
same_tool_failure: 3 # same tool failing N times (different args)
@@ -1786,7 +1787,7 @@ tool_loop_guardrails:
max_subagents: 50 # max subagents spawned per turn (0 = unlimited)
```
`hard_stop_enabled` defaults to `false` because interactive sessions have a human in the loop. In unattended deployments (gateway, cron, kanban workers) set it to `true` so repeated failures are blocked rather than only warned. See also [Docker / unattended deployments](docker.md).
`hard_stop_enabled` explicitly enables hard stops on every platform. When it remains `false`, `non_interactive_hard_stop_enabled` still enables them for unattended gateway/cron-style platforms while preserving warning-only behavior for CLI, TUI, Desktop, and ACP. Set `non_interactive_hard_stop_enabled: false` to opt an unattended deployment out. See also [Docker / unattended deployments](docker.md).
### Per-turn runaway-loop caps

View File

@@ -71,14 +71,11 @@ See the [Where the logs go](#where-the-logs-go) section below for the full routi
:::
:::note Tool-loop hard stops for unattended gateways
The `tool_loop_guardrails.hard_stop_enabled` setting defaults to `false`, which is reasonable for interactive CLI and TUI sessions where a person can see repeated tool-call warnings. In unattended gateway or server deployments, warnings alone may not stop an agent that gets stuck in a repeated tool-call loop. Operators who want circuit-breaker behavior should explicitly enable hard stops in the profile's `config.yaml`:
Unattended gateway and cron sessions enable tool-loop hard stops by default through `non_interactive_hard_stop_enabled`. Interactive CLI, TUI, Desktop, and ACP sessions remain warning-only. To opt an unattended deployment out in the profile's `config.yaml`:
```yaml
tool_loop_guardrails:
hard_stop_enabled: true
hard_stop_after:
exact_failure: 5
idempotent_no_progress: 5
non_interactive_hard_stop_enabled: false
```
:::