fix(guardrails): preserve interactive platform defaults
This commit is contained in:
committed by
Teknium
parent
ee2147f9e6
commit
384fc4bf83
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
:::
|
||||
|
||||
|
||||
Reference in New Issue
Block a user