max_tokens config propagation bugs and fixes
NousResearch/hermes-agent · cluster 782 · agent
Data as of
5 open items. Fix merged in #39864. Check it covers your case.
The short answer
- Known?
- Yes. 35 related items grouped here, 10 issues already closed.
- Fix event
- A fix was merged to main in PR #39864. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PRs #39864, #41694. That is separate from the fix event. - Still open
- 3 issues and 2 PRs.
Next step: read #39864, then check whether your report is one of the 3 still open.
Read #39864Pick up a Lab taskMultiple bug reports and fix attempts addressing max_tokens values from config.yaml being silently ignored and not passed through to AIAgent in CLI, gateway, and custom provider modes. Also includes related gemma4 context-length entries and a Kimi provider token parameter fix.
Open issues3
- Issue#20004max_tokens config from custom_providers is not passed to AIAgent
- Issue#28046Bug: max_tokens not read from custom_providers per-model config, always defaults to 4096
- Issue#46833[Setup]: Thinking models on `custom`/Ollama (OpenAI-compatible) providers always truncate, and Hermes requests the full GGUF context window causing VRAM blowups
Open PRs2
Fixes on record
-
#39864 fix(gateway): propagate max_tokens from config.yaml to AIAgent (#20741)
files:
cli.py,gateway/run.py,hermes_cli/runtime_provider.py,scripts/release.py,tests/gateway/test_max_tokens_propagation.pyShow the diff
-
cli.py@@ -3194,6 +3194,18 @@ def __init__( _config_model = (_model_config.get("default") or _model_config.get("model") or "") if isinstance(_model_config, dict) else (_model_config or "") _DEFAULT_CONFIG_MODEL = "" self.model = model or _config_model or _DEFAULT_CONFIG_MODEL + # Read max_tokens from config (env var override: HERMES_MAX_TOKENS) + _env_mt = os.environ.get("HERMES_MAX_TOKENS") + if _env_mt: + try: + self.max_tokens = [redacted:secret-assignment] + except (ValueError, TypeError): + self.max_tokens = None + elif isinstance(_model_config, dict): + _mt = _model_config.get("max_tokens") + self.max_tokens = _mt if isinstance(_mt, int) else None + else: + self.max_tokens = None # Auto-detect model from local server if still on default if self.model == _DEFAULT_CONFIG_MODEL: _base_url = (_model_config.get("base_url") or "") if isinstance(_model_config, dict) else "" @@ -5168,6 +5180,7 @@ def _init_agent(self, *, model_override: str = None, runtime_override: dict = No acp_command=runtime.get("command"), acp_args=runtime.get("args"), credential_pool=[redacted:secret-assignment]"credential_pool"), + max_tokens=[redacted:secret-assignment] max_iterations=self.max_turns, enabled_toolsets=self.enabled_toolsets, disabled_toolsets=self.disabled_toolsets, @@ -9284,6 +9297,7 @@ def run_background(): api_mode=turn_route["runtime"].get("api_mode"), acp_command=turn_route["runtime"].get("command"), acp_args=turn_route["runtime"].get("args"), + max_tokens=[redacted:secret-assignment]"runtime"].get("max_tokens"), max_iterations=self.max_turns, enabled_toolsets=self.enabled_toolsets, quiet_mode=True, -
gateway/run.py@@ -1179,6 +1179,7 @@ def _resolve_runtime_agent_kwargs() -> dict: from hermes_cli.runtime_provider import ( resolve_runtime_provider, format_runtime_provider_error, + _get_model_config, ) from hermes_cli.auth import AuthError, is_rate_limited_auth_error @@ -1200,6 +1201,26 @@ def _resolve_runtime_agent_kwargs() -> dict: except Exception as exc: raise RuntimeError(format_runtime_provider_error(exc)) from exc + model_cfg = _get_model_config() + max_tokens = None + _env_mt = os.environ.get("HERMES_MAX_TOKENS") + if _env_mt: + try: + max_tokens = [redacted:secret-assignment] + except (ValueError, TypeError): + max_tokens = None + elif isinstance(model_cfg, dict): + mt = model_cfg.get("max_tokens") + if isinstance(mt, int): + max_tokens = mt + # Fall back to a per-provider output cap (custom_providers max_output_tokens) + # only when the documented global model.max_tokens isn't set, so the global + # key always wins. + if max_tokens is None: + _runtime_mot = runtime.get("max_output_tokens") + if isinstance(_runtime_mot, int) and _runtime_mot > 0: + max_tokens = [redacted:secret-assignment] + return { "api_key": runtime.get("api_key"), "base_url": runtime.get("base_url"), @@ -1208,6 +1229,7 @@ def _resolve_runtime_agent_kwargs() -> dict: "command": runtime.get("command"), "args": list(runtime.get("args") or []), "credential_pool": runtime.get("credential_pool"), + "max_tokens": max_tokens, } @@ -2596,6 +2618,7 @@ def _resolve_session_agent_runtime( "api_key": override.get("api_key"), "base_url": override.get("base_url"), "api_mode": override.get("api_mode"), + "max_tokens": override.get("max_tokens"), } if override_runtime.get("api_key"): logger.debug( @@ -2693,6 +2716,7 @@ def _resolve_turn_agent_config(self, user_message: str, model: str, runtime_kwar "command": runtime_kwargs.get("command"), "args": list(runtime_kwargs.get("args") or []), "credential_pool": runtime_kwargs.get("credential_pool"), + "max_tokens": runtime_kwargs.get("max_tokens"), } route = { "model": model, -
hermes_cli/runtime_provider.py@@ -474,6 +474,21 @@ def _try_resolve_from_custom_pool( return None +def _lift_max_output_tokens(entry: Dict[str, Any], result: Dict[str, Any]) -> None: + """Propagate a per-provider output cap onto the resolved runtime dict. + + Accepts ``max_output_tokens`` or ``max_tokens`` on a ``custom_providers`` + entry so a provider block can pin its own output limit. Gateway and CLI + map this onto ``AIAgent.max_tokens`` only when the top-level + ``model.max_tokens`` isn't set, so the documented global key still wins. + """ + for _k in ("max_output_tokens", "max_tokens"): + _v = entry.get(_k) + if isinstance(_v, int) and _v > 0: + result["max_output_tokens"] = _v + return + + def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, Any]]: requested_norm = _normalize_custom_provider_name(requested_provider or "") if not requested_norm or requested_norm == "custom": @@ -541,6 +556,7 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An api_mode = _parse_api_mode(entry.get("api_mode") or entry.get("transport")) if api_mode: result["api_mode"] = api_mode + _lift_max_output_tokens(entry, result) return result # Also check the 'name' field if present display_name = entry.get("name", "") @@ -562,6 +578,7 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An api_mode = _parse_api_mode(entry.get("api_mode") or entry.get("transport")) if api_mode: result["api_mode"] = api_mode + _lift_max_output_tokens(entry, result) return result # Fall back to custom_providers: list (legacy format) @@ -611,6 +628,7 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An model_name = str(entry.get("model", "") or "").strip() if model_name: result["model"] = model_name + _lift_max_output_tokens(entry, result) return result return None @@ -699,6 +717,8 @@ def _resolve_named_custom_runtime( model_name = custom_provider.get("model") if model_name: pool_result["model"] = model_name + if isinstance(custom_provider.get("max_output_tokens"), int): + pool_result["max_output_tokens"] = custom_provider["max_output_tokens"] request_overrides = _custom_provider_request_overrides(custom_provider) if request_overrides: pool_result["request_overrides"] = { @@ -736,6 +756,8 @@ def _resolve_named_custom_runtime( # provider name differs from the actual model string the API expects. if custom_provider.get("model"): result["model"] = custom_provider["model"] + if isinstance(custom_provider.get("max_output_tokens"), int): + result["max_output_tokens"] = custom_provider["max_output_tokens"] request_overrides = _custom_provider_request_overrides(custom_provider) if request_overrides: result["request_overrides"] = request_overrides -
scripts/release.py@@ -45,6 +45,7 @@ # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "[redacted:email]": "ViewWay", "[redacted:email]": "harjothkhara", "[redacted:email]": "HeLLGURD", "[redacted:email]": "dirtyren", -
tests/gateway/test_max_tokens_propagation.py@@ -0,0 +1,178 @@ +"""Regression tests for max_tokens propagation from config.yaml to AIAgent. + +Covers #20741: `model.max_tokens` was silently dropped before reaching the +gateway-spawned agent, so providers without a hardcoded default (OpenRouter +free models, Ollama Cloud, custom OpenAI-compatible endpoints) truncated long +generations with `finish_reason="length"`. + +Precedence verified here: + HERMES_MAX_TOKENS env > model.max_tokens > per-provider + max_output_tokens > None +""" + +import importlib +import os +import sys +import textwrap + +import pytest + + [redacted:email] +def isolated_home(tmp_path, monkeypatch): + """Isolated HERMES_HOME with a writable config.yaml and a clean module cache. + + These tests deliberately re-import ``hermes_cli`` / ``gateway`` so each + config write is read fresh. To avoid leaking that purge into sibling test + files in the same worker (which breaks their import-time mocks), we snapshot + the affected modules and restore them on teardown. + """ + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("HERMES_MAX_TOKENS", raising=False) + + _saved = { + k: v + for k, v in sys.modules.items() + if k.startswith(("hermes_cli", "gateway")) + } + + def write_cfg(body: str) -> None: + (hermes_home / "config.yaml").write_text(textwrap.dedent(body)) + + def fresh_gateway(): + for mod in list(sys.modules.keys()): + if mod.startswith(("hermes_cli", "gateway")): + del sys.modules[mod] + return importlib.import_module("gateway.run") + + try: + yield write_cfg, fresh_gateway + finally: + # Drop anything we (re)imported, then restore the pre-test snapshot so + # the next test file sees the module objects it was loaded with. + for k in list(sys.modules.keys()): + if k.startswith(("hermes_cli", "gateway")): + del sys.modules[k] + sys.modules.update(_saved) + + +def test_top_level_max_tokens_propagates(isolated_home): + """model.max_tokens is read into the gateway runtime kwargs (#20741).""" + write_cfg, fresh_gateway = isolated_home + write_cfg( + """ + model: + default: glm-5.1 + provider: openrouter + max_tokens: 16384 + """ + ) + grun = fresh_gateway() + kw = grun._resolve_runtime_agent_kwargs() + assert kw["max_tokens"] == 16384 + + +def test_per_provider_max_output_tokens_fallback(isolated_home): + """A custom provider's max_output_tokens fills in when no global is set.""" + write_cfg, fresh_gateway = isolated_home + write_cfg( + """ + model: + default: glm-5.1 + provider: mylocal + providers: + mylocal: + api: http://localhost:11434/v1 + api_key: [redacted:secret-assignment] + default_model: glm-5.1 + max_output_tokens: 12000 + """ + ) + grun = fresh_gateway() + kw = grun._resolve_runtime_agent_kwargs() + assert kw["max_tokens"] == 12000 + + +def test_global_max_tokens_beats_per_provider(isolated_home): + """The documented global model.max_tokens wins over a provider cap.""" + write_cfg, fresh_gateway = isolated_home + write_cfg( + """ + model: + default: glm-5.1 + provider: mylocal + max_tokens: 16384 + providers: + mylocal: + api: http://localhost:11434/v1 + api_key: [redacted:secret-assignment] + default_model: glm-5.1 + max_output_tokens: 12000 + """ + ) + grun = fresh_gateway() + kw = grun._resolve_runtime_agent_kwargs() + assert kw["max_tokens"] == 16384 + + +def test_env_override_beats_everything(isolated_home, monkeypatch): + """HERMES_MAX_TOKENS is the internal override mechanism (highest priority).""" + write_cfg, fresh_gateway = isolated_home + monkeypatch.setenv("HERMES_MAX_TOKENS", "2048") + write_cfg( + """ + model: + default: glm-5.1 + provider: mylocal + max_tokens: 16384 + providers: + mylocal: + api: http://localhost:11434/v1 + api_key: [redacted:secret-assignment] + default_model: glm-5.1 + max_output_tokens: 12000 + """ + ) + grun = fresh_gateway() + kw = grun._resolve_runtime_agent_kwargs() + assert kw["max_tokens"] == 2048 + + +def test_no_config_leaves_max_tokens_none(isolated_home): + """No cap configured anywhere -> max_tokens is None (no spurious limit).""" + write_cfg, fresh_gateway = isolated_home + write_cfg( + """ + model: + default: glm-5.1 + provider: openrouter + """ + ) + grun = fresh_gateway() + kw = grun._resolve_runtime_agent_kwargs() + assert kw["max_tokens"] is None + + +def test_lift_helper_accepts_alias_and_rejects_garbage(isolated_home): + """_lift_max_output_tokens accepts both keys, ignores non-positive/non-int.""" + write_cfg, _ = isolated_home + write_cfg("model:\n provider: openrouter\n") + for mod in list(sys.modules.keys()): + if mod.startswith("hermes_cli"): + del sys.modules[mod] + rp = importlib.import_module("hermes_cli.runtime_provider") + + out: dict = {} + rp._lift_max_output_tokens({"max_output_tokens": 8192}, out) + assert out["max_output_tokens"] == 8192 + + out = {} + rp._lift_max_output_tokens({"max_tokens": 4096}, out) + assert out["max_output_tokens"] == 4096 + + for bad in ({"max_output_tokens": 0}, {"max_output_tokens": "x"}, {}): + out = {} + rp._lift_max_output_tokens(bad, out) + assert "max_output_tokens" not in out
-
-
#41694 fix(ollama): set default_max_tokens=65536 for custom/Ollama provider
files:
plugins/model-providers/custom/__init__.py,scripts/release.py
18 PRs closed without merging
From the close comments: done elsewhere 4 · duplicate 3 · superseded by another PR 3 · merged via another change 2 · closed without comment 1 · stale or abandoned 1 · other 4
- #787 fix: support max_tokens config for custom providers · stale or abandoned
- #788 fix: support max_tokens config for custom providers · closed without comment
- #5001 fix: wire model.max_tokens from config to Agent · other
- #10924 fix(runtime): propagate configured model max tokens · other
-
#10992 fix(cli): pass model.max_tokens into CLI and gateway agents · duplicate →
#10924
Show 13 more
-
#19991 fix: properly pass model.max_tokens config to AIAgent in gateway · done elsewhere →
14275d7 -
#20121 fix(gateway): honor custom_providers max_tokens when constructing AIAgent · superseded by another PR →
#20149 -
#20149 fix(gateway): honor max_tokens from custom_providers / providers entries (#20004) · duplicate →
#20121,#20004 -
#20769 fix(config): propagate max_tokens from config.yaml to AI transport (#20741) · done elsewhere →
#39864,#20741 -
#20804 fix(gateway): propagate max_tokens from config.yaml to AIAgent · merged via another change →
cf78659,1c909e7,#39864 - #28142 fix(agent_init): read max_tokens from custom_providers per-model config · done elsewhere
-
#28154 fix(agent): honor custom provider max_tokens · duplicate →
#28142,#28046,#20004,#18445,#20705 -
#28786 feat: per-provider max_tokens via custom_providers models.<model>.max_tokens · other →
14275d7 -
#28988 fix: read max_tokens from custom_providers per-model config · superseded by another PR →
#28995 - #35518 feat(custom_providers): per-model max_tokens with switch/fallback re-resolution · other
-
#39624 fix(ollama): set default_max_tokens=4096 for custom/Ollama provider · merged via another change →
09ec26c,#41694 -
#57548 fix(agent): honor custom_providers per-model max_tokens at startup · superseded by another PR →
#57704,#31829,#35327,#57548,#28154,#57713,#31365,#57536,#57248,#57696,#47821 -
#64426 fix(providers): resolve custom:<name> slug to custom profile for wire path (#39281) · done elsewhere →
c88723d,#39281,#41694
Closed issues · 10
- Closed#782feat: support HERMES_MAX_TOKENS env var / config.yaml max_tokens for custom providers
- Closed#4404[Bug]: model.max_tokens in config.yaml has no effect — setting is never passed to AIAgent
- Closed#10917[Bug]: model.max_tokens in config.yaml ignored for CLI/WebUI — thinking blocks and long responses silently truncated with local models
- Closed#11443model.max_tokens from config.yaml is ignored in both Gateway and CLI modes
- Closed#15037feat(config): support per-model max_tokens in custom_providers config
- Closed#20741Bug: `max_tokens` from config.yaml is silently ignored — never propagated to AIAgent, causing output truncation on Ollama Cloud / zai / custom endpoints
- Closed#28782feat: custom_providers should support per-provider max_tokens override
- Closed#39281[Bug]: Hermes fails to work using gemma4 with ollama backend
- Closed#45924[Bug]: hermes + gemma 4 12b
- Closed#49297[Bug]: Hermes fails to work using gemma4 with ollama backend
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.05: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab