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:
Teknium
2026-08-20 02:37:49 -07:00
parent 64505a2b83
commit c32119b12c
8 changed files with 45 additions and 30 deletions
+1 -1
View File
@@ -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
View File
@@ -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
+5 -1
View File
@@ -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
View File
@@ -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,
+6 -2
View File
@@ -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)
+13 -12
View File
@@ -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:
+4 -5
View File
@@ -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.