feat(config): default agent.max_turns to unlimited; accept inf/infinity/null spellings
Builds on @fattchris resolve_turn_limit salvage (#67696): flips the default from a numeric cap to unlimited across all construction paths (CLI, agent_init, run_agent subagents), adds inf/infinity/null to the unlimited spellings, and sets DEFAULT_CONFIG agent.max_turns to null. The turn cap caused more problems than it solved (silent mid-task truncation).
This commit is contained in:
+1
-1
@@ -520,7 +520,7 @@ def init_agent(
|
||||
command: str = None,
|
||||
args: list[str] | None = None,
|
||||
model: str = "",
|
||||
max_iterations: int = 90, # Default tool-calling iterations (shared with subagents)
|
||||
max_iterations: int = sys.maxsize, # Default: unlimited tool-calling iterations (shared with subagents)
|
||||
enabled_toolsets: List[str] = None,
|
||||
disabled_toolsets: List[str] = None,
|
||||
save_trajectories: bool = False,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
fattchris
|
||||
+14
-8
@@ -3092,29 +3092,35 @@ 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"})
|
||||
_UNLIMITED_SPELLINGS = frozenset({
|
||||
"none", "null", "unlimited", "infinite", "infinity", "inf",
|
||||
"∞", "-1", "0",
|
||||
})
|
||||
|
||||
|
||||
def resolve_turn_limit(raw: Any, default: int = 90) -> int:
|
||||
def resolve_turn_limit(raw: Any, default: int = TURN_LIMIT_UNLIMITED) -> 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``).
|
||||
- ``int`` / ``float`` → ``int(raw)`` (floats truncated; values ≤ 0 mean
|
||||
"no limit" → :data:`TURN_LIMIT_UNLIMITED`).
|
||||
- numeric string (``"120"``) → ``int(raw)``.
|
||||
- ``"none"`` / ``"unlimited"`` / ``"infinite"`` / ``"-1"`` / ``"0"``
|
||||
- ``"none"`` / ``"null"`` / ``"unlimited"`` / ``"infinite"`` /
|
||||
``"infinity"`` / ``"inf"`` / ``"∞"`` / ``"-1"`` / ``"0"``
|
||||
(case-insensitive, whitespace-tolerant) → :data:`TURN_LIMIT_UNLIMITED`.
|
||||
- YAML ``None`` / ``null`` (the value is explicitly absent) → ``default``.
|
||||
- YAML ``None`` / ``null`` / absent value → ``default`` (which is itself
|
||||
:data:`TURN_LIMIT_UNLIMITED` — max_turns is unlimited by 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.
|
||||
the default (unlimited) 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".
|
||||
becomes a first-class supported spelling of "unlimited". max_turns is
|
||||
unlimited unless the user sets an explicit positive integer cap.
|
||||
"""
|
||||
if raw is None:
|
||||
return default
|
||||
|
||||
@@ -43,7 +43,11 @@ DEFAULT_CONFIG = {
|
||||
"terminal_continue": True,
|
||||
},
|
||||
"agent": {
|
||||
"max_turns": 500,
|
||||
# Unlimited by default. The agent turn cap caused more problems than
|
||||
# it solved (silent mid-task truncation). null = unlimited; set a
|
||||
# positive integer to cap, or use "none"/"unlimited"/"inf"/0/-1 —
|
||||
# all normalized by hermes_cli.config.resolve_turn_limit.
|
||||
"max_turns": None,
|
||||
# Optional wall-clock budget in seconds per conversation run.
|
||||
# null/absent = feature fully off (zero behavior change). When set,
|
||||
# the agent gets a one-time wrap-up notice at 80% elapsed and
|
||||
|
||||
+1
-1
@@ -443,7 +443,7 @@ class AIAgent:
|
||||
command: str = None,
|
||||
args: list[str] | None = None,
|
||||
model: str = "",
|
||||
max_iterations: int = 90, # Default tool-calling iterations (shared with subagents)
|
||||
max_iterations: int = sys.maxsize, # Default: unlimited tool-calling iterations (shared with subagents)
|
||||
tool_delay: float = None, # Deprecated: accepted for compatibility, ignored
|
||||
enabled_toolsets: List[str] = None,
|
||||
disabled_toolsets: List[str] = None,
|
||||
|
||||
@@ -73,10 +73,14 @@ def _make_cli(env_overrides=None, config_overrides=None, **kwargs):
|
||||
class TestMaxTurnsResolution:
|
||||
"""max_turns must always resolve to a positive integer, never None."""
|
||||
|
||||
def test_default_max_turns_is_integer(self):
|
||||
def test_default_max_turns_is_unlimited(self):
|
||||
# Default is now unlimited (max_turns caused more problems than it
|
||||
# solved). Still a positive int (the sys.maxsize sentinel), so loop
|
||||
# conditions like `count < max_iterations` keep working.
|
||||
import sys
|
||||
cli = _make_cli()
|
||||
assert isinstance(cli.max_turns, int)
|
||||
assert cli.max_turns == 500
|
||||
assert cli.max_turns == sys.maxsize
|
||||
|
||||
def test_explicit_max_turns_honored(self):
|
||||
cli = _make_cli(max_turns=25)
|
||||
|
||||
@@ -60,39 +60,40 @@ class TestUnlimitedSpellings:
|
||||
|
||||
class TestAbsentAndDefault:
|
||||
def test_none_returns_default(self):
|
||||
assert resolve_turn_limit(None) == 90
|
||||
# Default is now unlimited (max_turns caused more problems than it solved).
|
||||
assert resolve_turn_limit(None) == TURN_LIMIT_UNLIMITED
|
||||
|
||||
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
|
||||
assert resolve_turn_limit("") == TURN_LIMIT_UNLIMITED
|
||||
|
||||
def test_whitespace_only_returns_default(self):
|
||||
assert resolve_turn_limit(" ") == 90
|
||||
assert resolve_turn_limit(" ") == TURN_LIMIT_UNLIMITED
|
||||
|
||||
def test_absent_env_var_returns_default(self):
|
||||
"""Simulates os.getenv() returning None when HERMES_MAX_ITERATIONS unset."""
|
||||
assert resolve_turn_limit(None) == 90
|
||||
assert resolve_turn_limit(None) == TURN_LIMIT_UNLIMITED
|
||||
|
||||
|
||||
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
|
||||
assert resolve_turn_limit(True) == TURN_LIMIT_UNLIMITED
|
||||
assert resolve_turn_limit(False) == TURN_LIMIT_UNLIMITED
|
||||
|
||||
def test_garbage_string_returns_default(self):
|
||||
assert resolve_turn_limit("garbage") == 90
|
||||
assert resolve_turn_limit("not_a_number") == 90
|
||||
assert resolve_turn_limit("garbage") == TURN_LIMIT_UNLIMITED
|
||||
assert resolve_turn_limit("not_a_number") == TURN_LIMIT_UNLIMITED
|
||||
|
||||
def test_list_returns_default(self):
|
||||
assert resolve_turn_limit([]) == 90
|
||||
assert resolve_turn_limit([90]) == 90
|
||||
assert resolve_turn_limit([]) == TURN_LIMIT_UNLIMITED
|
||||
assert resolve_turn_limit([90]) == TURN_LIMIT_UNLIMITED
|
||||
|
||||
def test_dict_returns_default(self):
|
||||
assert resolve_turn_limit({}) == 90
|
||||
assert resolve_turn_limit({"max_turns": 90}) == 90
|
||||
assert resolve_turn_limit({}) == TURN_LIMIT_UNLIMITED
|
||||
assert resolve_turn_limit({"max_turns": 90}) == TURN_LIMIT_UNLIMITED
|
||||
|
||||
|
||||
class TestSentinelProperties:
|
||||
|
||||
@@ -1023,14 +1023,13 @@ Instead, when the budget is actually exhausted (500/500), Hermes injects one mes
|
||||
|
||||
```yaml
|
||||
agent:
|
||||
max_turns: 90 # Max iterations per conversation turn (default: 90)
|
||||
# Set to "none", "unlimited", or 0 for no limit
|
||||
max_turns: none # Iterations per conversation turn (default: none = unlimited)
|
||||
# Set a positive integer to cap; "none"/"null"/
|
||||
# "unlimited"/"inf"/"infinity"/"infinite"/0/-1 = no limit
|
||||
api_max_retries: 3 # Retries per provider before fallback engages (default: 3)
|
||||
```
|
||||
|
||||
When the iteration budget is fully exhausted, the CLI shows a notification to the user: `⚠ Iteration budget reached (500/500) — response may be incomplete`.
|
||||
|
||||
`agent.max_turns` accepts the following "unlimited" spellings (case-insensitive): `"none"`, `"unlimited"`, `"infinite"`, `0`, `-1`. These resolve to a sentinel value (`sys.maxsize`) so the loop never exits on a turn count. Use this for long-running autonomous sessions where the turn budget would otherwise interrupt work.
|
||||
`agent.max_turns` is **unlimited by default** — the turn cap caused more problems than it solved (silent mid-task truncation), so out of the box Hermes runs a conversation turn to completion. To impose a cap, set a positive integer. To be explicit about "no limit", any of these case-insensitive spellings work: `"none"`, `"null"`, `"unlimited"`, `"infinite"`, `"infinity"`, `"inf"`, `0`, `-1` (they resolve to a `sys.maxsize` sentinel so the loop never exits on a turn count).
|
||||
|
||||
`agent.api_max_retries` controls how many times Hermes retries a provider API call on transient errors (rate limits, connection drops, 5xx) **before** fallback-provider switching engages. The default is `3` — four attempts total. If you have [fallback providers](/user-guide/features/fallback-providers) configured and want to fail over faster, drop this to `0` so the first transient error on your primary immediately hands off to the fallback instead of churning retries against the flaky endpoint.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user