diff --git a/agent/agent_init.py b/agent/agent_init.py index b839b35576..d8a5bd75e9 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -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, diff --git a/contributors/emails/fattchris@users.noreply.github.com b/contributors/emails/fattchris@users.noreply.github.com new file mode 100644 index 0000000000..3c4df87dfe --- /dev/null +++ b/contributors/emails/fattchris@users.noreply.github.com @@ -0,0 +1 @@ +fattchris diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 77731bfc3d..5b166427a7 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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 diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 5a3ac68b4f..e20450b98b 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -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 diff --git a/run_agent.py b/run_agent.py index d3588f8f48..f5c4de1274 100644 --- a/run_agent.py +++ b/run_agent.py @@ -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, diff --git a/tests/cli/test_cli_init.py b/tests/cli/test_cli_init.py index 15e6c653be..cca40f831e 100644 --- a/tests/cli/test_cli_init.py +++ b/tests/cli/test_cli_init.py @@ -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) diff --git a/tests/hermes_cli/test_resolve_turn_limit.py b/tests/hermes_cli/test_resolve_turn_limit.py index 417535ffb6..2cacb9d08d 100644 --- a/tests/hermes_cli/test_resolve_turn_limit.py +++ b/tests/hermes_cli/test_resolve_turn_limit.py @@ -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: diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index f64672c850..13430b9ad4 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -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.