diff --git a/agent/tool_guardrails.py b/agent/tool_guardrails.py index 4a57e96446..777cb9775d 100644 --- a/agent/tool_guardrails.py +++ b/agent/tool_guardrails.py @@ -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) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index a564f19524..24991f6eaf 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -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: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 36c40573d3..09fb268c52 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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 # ============================================================================= diff --git a/scripts/release.py b/scripts/release.py index a6fa757ae1..b906fdd4fb 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -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", diff --git a/tests/agent/test_tool_guardrails.py b/tests/agent/test_tool_guardrails.py index d8d5b4f8f5..05c2560261 100644 --- a/tests/agent/test_tool_guardrails.py +++ b/tests/agent/test_tool_guardrails.py @@ -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 diff --git a/tests/run_agent/test_tool_call_guardrail_runtime.py b/tests/run_agent/test_tool_call_guardrail_runtime.py index 5ef47efc72..fbc0a51460 100644 --- a/tests/run_agent/test_tool_call_guardrail_runtime.py +++ b/tests/run_agent/test_tool_call_guardrail_runtime.py @@ -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"} diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index b1d5570576..2a0e1619ec 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -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 diff --git a/website/docs/user-guide/docker.md b/website/docs/user-guide/docker.md index da737a4b17..dbdb77cb21 100644 --- a/website/docs/user-guide/docker.md +++ b/website/docs/user-guide/docker.md @@ -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 ``` :::