max_tokens config wiring and fallback coverage
NousResearch/hermes-agent · cluster 12790 · agent
Data as of
Nothing open on GitHub. Fix merged in #21298. Check it covers your case.
The short answer
- Known?
- Yes. 9 related items grouped here, 2 issues already closed.
- Fix event
- A fix was merged to main in PR #21298. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #55958. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These members address missing or incomplete propagation of max_tokens settings across various providers including Anthropic, NVIDIA NIM, Bedrock, LiteLLM, and custom OpenAI-compatible proxies.
Open issues0
Open PRs0
Fixes on record
-
#21298 fix(agent): honor configured model.max_tokens (salvage #19452)
files:
gateway/run.py,run_agent.py,tests/gateway/test_agent_cache.py,tests/run_agent/test_run_agent.pyShow the diff
-
gateway/run.py@@ -12251,6 +12251,7 @@ async def _run_process_watcher(self, watcher: dict) -> None: # Add more here as new baked-at-construction config settings are added. _CACHE_BUSTING_CONFIG_KEYS: tuple = ( ("model", "context_length"), + ("model", "max_tokens"), ("compression", "enabled"), ("compression", "threshold"), ("compression", "target_ratio"), -
run_agent.py@@ -1901,8 +1901,35 @@ def __init__( _aux_context_config = None self._aux_compression_context_length_config = _aux_context_config - # Read explicit context_length override from model config + # Read explicit model output-token override from config when the + # caller did not pass one directly. _model_cfg = _agent_cfg.get("model", {}) + if self.max_tokens is None and isinstance(_model_cfg, dict): + _config_max_tokens = [redacted:secret-assignment]"max_tokens") + if _config_max_tokens is not None: + try: + if isinstance(_config_max_tokens, bool): + raise ValueError + _parsed_max_tokens = [redacted:secret-assignment] + if _parsed_max_tokens <= 0: + raise ValueError + self.max_tokens = [redacted:secret-assignment] + except (TypeError, ValueError): + logger.warning( + "Invalid model.max_tokens in config.yaml: %r — " + "must be a positive integer (e.g. 4096). " + "Falling back to provider default.", + _config_max_tokens, + ) + print( + f"\n⚠ Invalid model.max_tokens in config.yaml: {_config_max_tokens!r}\n" + f" Must be a positive integer (e.g. 4096).\n" + f" Falling back to provider default.\n", + file=sys.stderr, + ) + self._session_init_model_config["max_tokens"] = self.max_tokens + + # Read explicit context_length override from model config if isinstance(_model_cfg, dict): _config_context_length = _model_cfg.get("context_length") else: -
tests/gateway/test_agent_cache.py@@ -127,6 +127,21 @@ def test_context_length_change_busts_cache(self): ) assert sig1 != sig2 + def test_max_tokens_change_busts_cache(self): + """Editing model.max_tokens in config must produce a new signature.""" + from gateway.run import GatewayRunner + + runtime = {"api_key": "k", "base_url": "u", "provider": "p"} + sig1 = GatewayRunner._agent_config_signature( + "m", runtime, [], "", + cache_keys={"model.max_tokens": 4096}, + ) + sig2 = GatewayRunner._agent_config_signature( + "m", runtime, [], "", + cache_keys={"model.max_tokens": 8192}, + ) + assert sig1 != sig2 + def test_compression_threshold_change_busts_cache(self): from gateway.run import GatewayRunner @@ -195,9 +210,16 @@ def test_reads_model_context_length(self): from gateway.run import GatewayRunner out = GatewayRunner._extract_cache_busting_config( - {"model": {"context_length": 272_000, "provider": "openrouter"}} + { + "model": { + "context_length": 272_000, + "max_tokens": 4096, + "provider": "openrouter", + } + } ) assert out["model.context_length"] == 272_000 + assert out["model.max_tokens"] == 4096 def test_reads_compression_subkeys(self): from gateway.run import GatewayRunner -
tests/run_agent/test_run_agent.py@@ -724,6 +724,56 @@ def test_prompt_caching_cache_ttl_custom_1h(self): ) assert a._cache_ttl == "1h" + def test_model_max_tokens_from_config(self): + """model.max_tokens config populates the chat-completions request cap.""" + with ( + patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("terminal")), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + patch( + "hermes_cli.config.load_config", + return_value={"model": {"max_tokens": 4096}}, + ), + ): + a = AIAgent( + api_key="[redacted:secret-assignment]", + provider="custom", + model="claude-opus-4-6-thinking", + base_url="http://proxy.example/v1", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + kwargs = a._build_api_kwargs([{"role": "user", "content": "Hi"}]) + + assert a.max_tokens == 4096 + assert kwargs["max_tokens"] == 4096 + + def test_constructor_max_tokens_wins_over_config(self): + """Explicit constructor max_tokens keeps programmatic callers stable.""" + with ( + patch("run_agent.get_tool_definitions", return_value=[]), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + patch( + "hermes_cli.config.load_config", + return_value={"model": {"max_tokens": 4096}}, + ), + ): + a = AIAgent( + api_key="[redacted:secret-assignment]", + provider="custom", + model="claude-opus-4-6-thinking", + base_url="http://proxy.example/v1", + max_tokens=8192, + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + assert a.max_tokens == 8192 + def test_prompt_caching_cache_ttl_invalid_falls_back(self): """Non-Anthropic TTL values keep default 5m without raising.""" with (
-
-
#55958 fix(anthropic+feishu): model-gate max_tokens fallback; wire Feishu channel_prompt
files:
agent/chat_completion_helpers.py,plugins/platforms/feishu/adapter.py,tests/gateway/test_feishu_channel_prompts.pyShow the diff
-
agent/chat_completion_helpers.py@@ -741,14 +741,26 @@ def build_api_kwargs(agent, api_messages: list) -> dict: if agent.provider_data_collection: _prefs["data_collection"] = agent.provider_data_collection - # Claude max-output override on aggregators + # Anthropic-compatible max-output fallback (last resort only — applied in + # build_kwargs *after* ephemeral/user/profile max_tokens, never overriding + # an explicit value). Model-gated, not URL-gated: any chat-completions + # proxy serving a Claude/MiniMax/Qwen3 model needs max_tokens, because the + # Anthropic Messages API treats it as mandatory and proxies that omit it + # (AWS Bedrock, NVIDIA, LiteLLM, vLLM, corporate gateways) default as low + # as 4096 output tokens — easily exhausted by thinking + large tool calls + # like write_file/patch. OpenRouter/Nous were the only routes covered + # before; gating on _ANTHROPIC_OUTPUT_LIMITS membership covers them all. _ant_max = None - if (_is_or or _is_nous) and "claude" in (agent.model or "").lower(): - try: - from agent.anthropic_adapter import _get_anthropic_max_output + try: + from agent.anthropic_adapter import ( + _get_anthropic_max_output, + _ANTHROPIC_OUTPUT_LIMITS, + ) + _model_norm = (agent.model or "").lower().replace(".", "-") + if any(key in _model_norm for key in _ANTHROPIC_OUTPUT_LIMITS): _ant_max = _get_anthropic_max_output(agent.model) - except Exception: - pass + except Exception: + pass # Qwen session metadata _qwen_meta = None -
plugins/platforms/feishu/adapter.py@@ -2892,6 +2892,7 @@ async def _handle_reaction_event(self, event_type: str, data: Any) -> None: source=source, raw_message=data, message_id=message_id, + channel_prompt=self._resolve_channel_prompt(chat_id), timestamp=datetime.now(), ) logger.info("[Feishu] Routing reaction %s:%s on bot message %s as synthetic event", action, emoji_type, message_id) @@ -2954,6 +2955,7 @@ async def _handle_card_action_event(self, data: Any) -> None: source=source, raw_message=data, message_id=token or str(uuid.uuid4()), + channel_prompt=self._resolve_channel_prompt(chat_id), timestamp=datetime.now(), ) logger.info("[Feishu] Routing card action %r from %s in %s as synthetic command", action_tag, open_id, chat_id) @@ -3156,6 +3158,18 @@ def _clear_webhook_anomaly(self, remote_ip: str) -> None: # Inbound processing pipeline # ========================================================================= + def _resolve_channel_prompt(self, chat_id: str, parent_id: str | None = None) -> str | None: + """Resolve a Feishu per-channel system prompt. + + Mirrors the Discord/Slack behaviour so ``channel_prompts: {<chat_id>: + "<prompt>"}`` in ``PlatformConfig.extra`` is honoured for Feishu chats + instead of being silently ignored. + """ + from gateway.platforms.base import resolve_channel_prompt + _config = getattr(self, "config", None) + _extra = getattr(_config, "extra", None) or {} + return resolve_channel_prompt(_extra, chat_id, parent_id) + async def _process_inbound_message( self, *, @@ -3233,6 +3247,7 @@ async def _process_inbound_message( media_types=media_types, reply_to_message_id=reply_to_message_id, reply_to_text=reply_to_text, + channel_prompt=self._resolve_channel_prompt(chat_id, thread_id or None), timestamp=datetime.now(), ) await self._dispatch_inbound_event(normalized) -
tests/gateway/test_feishu_channel_prompts.py@@ -0,0 +1,88 @@ +"""Tests for Feishu per-channel prompt resolution. + +Feishu previously ignored ``channel_prompts`` config (unlike Discord/Slack). +These tests verify that ``_resolve_channel_prompt`` reads the adapter's +``config.extra`` and that the resolved prompt is attached to the dispatched +``MessageEvent`` for the inbound, reaction, and card-action paths. +""" + +import asyncio +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +from gateway.config import PlatformConfig + + +def _build_adapter(extra=None): + from plugins.platforms.feishu.adapter import FeishuAdapter + + adapter = FeishuAdapter.__new__(FeishuAdapter) + adapter.config = PlatformConfig(extra=extra or {}) + adapter._bot_open_id = "ou_bot" + adapter._bot_user_id = "" + adapter._bot_name = "Hermes" + adapter._download_feishu_message_resources = AsyncMock(return_value=([], [])) + adapter._fetch_message_text = AsyncMock(return_value=None) + adapter.get_chat_info = AsyncMock(return_value={"name": "Test Chat"}) + adapter._resolve_sender_profile = AsyncMock( + return_value={"user_id": "u1", "user_name": "Alice", "user_id_alt": None} + ) + adapter._resolve_source_chat_type = Mock(return_value="group") + adapter.build_source = Mock(return_value=SimpleNamespace(thread_id=None)) + adapter._dispatch_inbound_event = AsyncMock() + return adapter + + +def _run_inbound(adapter, chat_id="oc_chat"): + message = SimpleNamespace( + content=json.dumps({"text": "plain message"}), + message_type="text", + message_id="m", + mentions=[], + chat_id=chat_id, + parent_id=None, + upper_message_id=None, + thread_id=None, + ) + asyncio.run( + adapter._process_inbound_message( + data=message, message=message, sender_id=None, chat_type="group", message_id="m", + ) + ) + return adapter._dispatch_inbound_event.call_args.args[0] + + +def test_resolve_channel_prompt_exact_match(): + adapter = _build_adapter({"channel_prompts": {"oc_chat": "Be terse."}}) + assert adapter._resolve_channel_prompt("oc_chat") == "Be terse." + + +def test_resolve_channel_prompt_parent_fallback(): + adapter = _build_adapter({"channel_prompts": {"oc_parent": "Inherit me."}}) + assert adapter._resolve_channel_prompt("oc_thread", "oc_parent") == "Inherit me." + + +def test_resolve_channel_prompt_no_match_returns_none(): + adapter = _build_adapter({"channel_prompts": {"oc_other": "Nope."}}) + assert adapter._resolve_channel_prompt("oc_chat") is None + + +def test_resolve_channel_prompt_missing_config_is_safe(): + # __new__ adapter without a config attribute (defensive getattr path). + from plugins.platforms.feishu.adapter import FeishuAdapter + + bare = FeishuAdapter.__new__(FeishuAdapter) + assert bare._resolve_channel_prompt("oc_chat") is None + + +def test_inbound_event_carries_channel_prompt(): + adapter = _build_adapter({"channel_prompts": {"oc_chat": "Feishu role prompt."}}) + event = _run_inbound(adapter, chat_id="oc_chat") + assert event.channel_prompt == "Feishu role prompt." + + +def test_inbound_event_no_prompt_when_unconfigured(): + adapter = _build_adapter({"channel_prompts": {"oc_other": "Different chat."}}) + event = _run_inbound(adapter, chat_id="oc_chat") + assert event.channel_prompt is None
-
5 PRs closed without merging
From the close comments: done elsewhere 2 · closed without comment 1 · merged via another change 1 · superseded by another PR 1
-
#12811 fix: Anthropic max_tokens + Feishu channel_prompt + Discord free_response · done elsewhere →
#12790,#12805,#12750,#55958 -
#19387 fix(agent): read model.max_tokens from config.yaml · superseded by another PR →
#19452 - #19452 fix(agent): honor configured model max_tokens · closed without comment
-
#19515 fix(agent): honor model.max_tokens in config.yaml · merged via another change →
#19452,#21298 -
#19686 fix(transport): add default max_tokens for custom providers with tools · done elsewhere →
09ec26c,#19452,#19515
Closed issues · 2
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.20: 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