diff --git a/cli.py b/cli.py index e827d8c5f1..66d63321ae 100644 --- a/cli.py +++ b/cli.py @@ -5233,25 +5233,26 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): else: self.api_key = api_key or os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY") # Max turns priority: CLI arg > config file > env var > default + # All paths go through resolve_turn_limit() so that agent.max_turns + # accepts "none"/"unlimited" (→ sys.maxsize) in addition to ints. + # See hermes_cli.config.resolve_turn_limit for the full spelling table. + from hermes_cli.config import resolve_turn_limit as _resolve_turn_limit if max_turns is not None: # CLI arg was explicitly set - self.max_turns = max_turns - elif CLI_CONFIG["agent"].get("max_turns"): - self.max_turns = CLI_CONFIG["agent"]["max_turns"] - elif CLI_CONFIG.get("max_turns"): # Backwards compat: root-level max_turns + self.max_turns = _resolve_turn_limit(max_turns) + elif CLI_CONFIG["agent"].get("max_turns") is not None: + self.max_turns = _resolve_turn_limit(CLI_CONFIG["agent"]["max_turns"]) + elif CLI_CONFIG.get("max_turns") is not None: # Backwards compat: root-level max_turns # KEEP (evaluated for the v12 support-floor cleanup, July 2026): # no versioned config migration ever rewrote root-level max_turns # to agent.max_turns on disk — only load-time normalization # (_normalize_max_turns_config) folds it, and configs read through # other paths may bypass it. This fallback is therefore the only # safety net for configs that still carry the root key. - self.max_turns = CLI_CONFIG["max_turns"] - elif os.getenv("HERMES_MAX_ITERATIONS"): - try: - self.max_turns = int(os.getenv("HERMES_MAX_ITERATIONS", "")) - except (TypeError, ValueError): - self.max_turns = 500 + self.max_turns = _resolve_turn_limit(CLI_CONFIG["max_turns"]) else: - self.max_turns = 500 + # Env var bridge (set by gateway/run.py from config.yaml, or by the + # user directly). Empty/unset → default (unlimited). + self.max_turns = _resolve_turn_limit(os.getenv("HERMES_MAX_ITERATIONS")) # Wall-clock run budget: CLI flag wins over config; both optional. # None keeps the feature fully off (AIAgent stays dormant). @@ -5259,7 +5260,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): self.run_budget_seconds = run_budget else: self.run_budget_seconds = CLI_CONFIG["agent"].get("run_budget_seconds") - + # Parse and validate toolsets self.enabled_toolsets = toolsets from agent.skill_utils import parse_config_string_list diff --git a/cron/scheduler.py b/cron/scheduler.py index bd61e81fd9..c0cf76aa73 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -5411,8 +5411,14 @@ def run_job( logger.warning("Job '%s': failed to parse prefill messages file '%s': %s", job_id, pfpath, e) prefill_messages = None - # Max iterations - max_iterations = _cfg.get("agent", {}).get("max_turns") or _cfg.get("max_turns") or 500 + # Max iterations — resolved through resolve_turn_limit() so that + # agent.max_turns: none / unlimited → sys.maxsize sentinel, and + # explicit 0 / null / "none" are honored instead of skipped by `or`. + from hermes_cli.config import resolve_turn_limit as _resolve_turn_limit + _mt = _cfg.get("agent", {}).get("max_turns") + if _mt is None: + _mt = _cfg.get("max_turns") + max_iterations = _resolve_turn_limit(_mt) # Provider routing pr = _cfg.get("provider_routing") or {} diff --git a/gateway/run.py b/gateway/run.py index 3f3b9e41de..d23ab30a9d 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2049,6 +2049,9 @@ def _bridge_max_turns_from_config(home: "Path") -> None: agent_cfg = cfg.get("agent", {}) if isinstance(agent_cfg, dict) and "max_turns" in agent_cfg: + # Preserve the raw value's spelling (e.g. "none", "unlimited", "120") + # so resolve_turn_limit() in _current_max_iterations can interpret it. + # str() round-trips both Python None (→ "None") and YAML strings. os.environ["HERMES_MAX_ITERATIONS"] = str(agent_cfg["max_turns"]) # config-authoritative knobs for the session-search index (config.yaml # sessions.* wins over stale env; env stays the cross-process carrier). @@ -2061,12 +2064,16 @@ def _bridge_max_turns_from_config(home: "Path") -> None: def _current_max_iterations() -> int: - """Return the current per-turn iteration budget after runtime env refresh.""" + """Return the current per-turn iteration budget after runtime env refresh. + + Goes through :func:`hermes_cli.config.resolve_turn_limit` so that + ``agent.max_turns: none`` / ``unlimited`` (bridged into + ``HERMES_MAX_ITERATIONS`` as a string) resolves to the unlimited sentinel + instead of crashing ``int()``. + """ _reload_runtime_env_preserving_config_authority() - try: - return int(os.getenv("HERMES_MAX_ITERATIONS", "500")) - except (TypeError, ValueError): - return 500 + from hermes_cli.config import resolve_turn_limit as _resolve_turn_limit + return _resolve_turn_limit(os.getenv("HERMES_MAX_ITERATIONS")) from contextlib import contextmanager as _contextmanager diff --git a/hermes_cli/config.py b/hermes_cli/config.py index c25aa57399..77731bfc3d 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -3078,6 +3078,77 @@ def is_provider_enabled(provider_cfg: Optional[Dict[str, Any]]) -> bool: return bool(flag) +# Sentinel used when the user requests an unlimited turn budget. +# ``sys.maxsize`` (9,223,372,036,854,775,807) is chosen so it: +# - survives the ``str() → int()`` round-trip through the HERMES_MAX_ITERATIONS +# env-var bridge in gateway/run.py, +# - works correctly in every ``<``, ``>=``, and ``remaining = max - used`` +# comparison in agent/iteration_budget.py and agent/conversation_loop.py +# without requiring those call sites to learn about a special "unlimited" +# value, and +# - is large enough that no real conversation will ever reach it (a turn +# takes seconds; 9.2e18 turns would take ~10^11 years). +TURN_LIMIT_UNLIMITED = sys.maxsize + +# String spellings that mean "no limit". Lowercased, whitespace-stripped +# before comparison so ``"None"``, ``" unlimited "`` etc. all match. +_UNLIMITED_SPELLINGS = frozenset({"none", "unlimited", "infinite", "∞", "-1", "0"}) + + +def resolve_turn_limit(raw: Any, default: int = 90) -> int: + """Normalize a raw ``agent.max_turns`` value into an int iteration cap. + + Accepts: + - ``int`` / ``float`` → ``int(raw)`` (floats truncated; negatives rejected + → fall through to ``default``). + - numeric string (``"120"``) → ``int(raw)``. + - ``"none"`` / ``"unlimited"`` / ``"infinite"`` / ``"-1"`` / ``"0"`` + (case-insensitive, whitespace-tolerant) → :data:`TURN_LIMIT_UNLIMITED`. + - YAML ``None`` / ``null`` (the value is explicitly absent) → ``default``. + - Anything unparseable → ``default`` (with a debug log). + + The returned int is always ≥ 1, so loop conditions like + ``while api_call_count < agent.max_iterations`` behave correctly even when + the default path is taken. + + This is the single normalization point for the turn-limit value type. + Config-reading sites (cli.py, gateway/run.py, cron/scheduler.py) call this + instead of bare ``int(...)``, so ``agent.max_turns: none`` in config.yaml + becomes a first-class supported spelling of "unlimited". + """ + if raw is None: + return default + if isinstance(raw, bool): + # bool is a subclass of int; reject it explicitly so True/False don't + # silently become 1/0. + return default + if isinstance(raw, (int, float)): + n = int(raw) + if n <= 0: + return TURN_LIMIT_UNLIMITED + return n + if isinstance(raw, str): + s = raw.strip().lower() + if not s: + return default + if s in _UNLIMITED_SPELLINGS: + return TURN_LIMIT_UNLIMITED + try: + n = int(s) + except ValueError: + try: + n = int(float(s)) + except ValueError: + logger.debug("resolve_turn_limit: unparseable value %r → default %d", raw, default) + return default + if n <= 0: + return TURN_LIMIT_UNLIMITED + return n + # Unknown type (list, dict, …) — don't crash the agent over a bad config. + logger.debug("resolve_turn_limit: unsupported type %s (%r) → default %d", type(raw).__name__, raw, default) + return default + + def cfg_get(cfg: Optional[Dict[str, Any]], *keys: str, default: Any = None) -> Any: """Traverse nested dict keys safely, returning ``default`` on any miss. diff --git a/tests/hermes_cli/test_resolve_turn_limit.py b/tests/hermes_cli/test_resolve_turn_limit.py new file mode 100644 index 0000000000..f4675dec15 --- /dev/null +++ b/tests/hermes_cli/test_resolve_turn_limit.py @@ -0,0 +1,132 @@ +"""Tests for :func:`hermes_cli.config.resolve_turn_limit` and the +``TURN_LIMIT_UNLIMITED`` sentinel. + +Covers the full spelling table (int, float, numeric string, ``"none"``, +``"unlimited"``, ``"infinite"``, ``"-1"``, ``"0"``, YAML ``None``, bool, +garbage) and the str→int env-var round-trip that the gateway bridge relies on. +""" +import sys +import pytest + +from hermes_cli.config import resolve_turn_limit, TURN_LIMIT_UNLIMITED + + +class TestNumericValues: + def test_int_passthrough(self): + assert resolve_turn_limit(90) == 90 + assert resolve_turn_limit(120) == 120 + assert resolve_turn_limit(1) == 1 + + def test_float_truncated(self): + assert resolve_turn_limit(3.7) == 3 + assert resolve_turn_limit(3.0) == 3 + assert resolve_turn_limit(100.9) == 100 + + def test_numeric_string(self): + assert resolve_turn_limit("120") == 120 + assert resolve_turn_limit("3") == 3 + assert resolve_turn_limit("3.7") == 3 # float string → int + + def test_negative_int_is_unlimited(self): + assert resolve_turn_limit(-5) == TURN_LIMIT_UNLIMITED + + def test_negative_string_is_unlimited(self): + assert resolve_turn_limit("-1") == TURN_LIMIT_UNLIMITED + assert resolve_turn_limit("-42") == TURN_LIMIT_UNLIMITED + + +class TestUnlimitedSpellings: + @pytest.mark.parametrize("spelling", [ + "none", "None", "NONE", "nOnE", + "unlimited", "UNLIMITED", "Unlimited", + "infinite", "INFINITE", + "∞", + "-1", "0", + ]) + def test_string_spellings_resolve_to_sentinel(self, spelling): + assert resolve_turn_limit(spelling) == TURN_LIMIT_UNLIMITED + + @pytest.mark.parametrize("spelling", [" none ", " unlimited ", "\tinfinite\t"]) + def test_whitespace_tolerant(self, spelling): + assert resolve_turn_limit(spelling) == TURN_LIMIT_UNLIMITED + + def test_zero_int_is_unlimited(self): + assert resolve_turn_limit(0) == TURN_LIMIT_UNLIMITED + + def test_zero_float_is_unlimited(self): + assert resolve_turn_limit(0.0) == TURN_LIMIT_UNLIMITED + + +class TestAbsentAndDefault: + def test_none_returns_default(self): + assert resolve_turn_limit(None) == 90 + + def test_none_custom_default(self): + assert resolve_turn_limit(None, default=500) == 500 + + def test_empty_string_returns_default(self): + assert resolve_turn_limit("") == 90 + + def test_whitespace_only_returns_default(self): + assert resolve_turn_limit(" ") == 90 + + def test_absent_env_var_returns_default(self): + """Simulates os.getenv() returning None when HERMES_MAX_ITERATIONS unset.""" + assert resolve_turn_limit(None) == 90 + + +class TestInvalidInputs: + def test_bool_rejected(self): + # bool is an int subclass — must not silently become 1/0 + assert resolve_turn_limit(True) == 90 + assert resolve_turn_limit(False) == 90 + + def test_garbage_string_returns_default(self): + assert resolve_turn_limit("garbage") == 90 + assert resolve_turn_limit("not_a_number") == 90 + + def test_list_returns_default(self): + assert resolve_turn_limit([]) == 90 + assert resolve_turn_limit([90]) == 90 + + def test_dict_returns_default(self): + assert resolve_turn_limit({}) == 90 + assert resolve_turn_limit({"max_turns": 90}) == 90 + + +class TestSentinelProperties: + def test_sentinel_is_sys_maxsize(self): + assert TURN_LIMIT_UNLIMITED == sys.maxsize + + def test_sentinel_str_int_round_trip(self): + """The gateway bridge writes str(value) to HERMES_MAX_ITERATIONS, + then _current_max_iterations reads it back. The sentinel must survive.""" + s = str(TURN_LIMIT_UNLIMITED) + assert int(s) == TURN_LIMIT_UNLIMITED + + def test_sentinel_greater_than_any_realistic_count(self): + assert TURN_LIMIT_UNLIMITED > 10_000_000 + assert TURN_LIMIT_UNLIMITED > 1_000_000_000 + + +class TestEnvVarBridgeSimulation: + """Simulates the full gateway chain: config value → str() → env var → + resolve_turn_limit().""" + + def test_none_string_round_trip(self): + # config has: agent.max_turns: "none" + env_val = str("none") + assert resolve_turn_limit(env_val) == TURN_LIMIT_UNLIMITED + + def test_yaml_null_round_trip(self): + # YAML bare 'none' parses to Python None, str(None) = "None" + env_val = str(None) # "None" + assert resolve_turn_limit(env_val) == TURN_LIMIT_UNLIMITED + + def test_int_round_trip(self): + env_val = str(120) + assert resolve_turn_limit(env_val) == 120 + + def test_unlimited_string_round_trip(self): + env_val = str("unlimited") + assert resolve_turn_limit(env_val) == TURN_LIMIT_UNLIMITED