{"repo":"NousResearch/hermes-agent","public_id":"12790","display_label":"max_tokens config wiring and fallback coverage","summary":"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.","subsystem":"agent","counts":{"members":9,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":21298},"resolution":{"state":"partially_resolved","by":"pr-55958"},"freshness":{"state":"verified","last_verified_at":"2026-07-01T00:20:42+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.20444444444444446},"fix_edges":[{"issue_number":12790,"pr_number":55958,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":12805,"pr_number":55958,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":19360,"pr_number":21298,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":12790,"title":"[Bug]: Anthropic max_tokens fallback only fires for OpenRouter/Nous — silently skipped for Bedrock, NVIDIA, LiteLLM, and every other chat-completions proxy","url":null,"state":"closed"},{"kind":"issue","number":19360,"title":"[Bug] Missing `max_tokens` in API request body causes HTTP 400 when tools are used with custom OpenAI-compatible proxy","url":null,"state":"closed"},{"kind":"pr","number":12811,"title":"fix: Anthropic max_tokens + Feishu channel_prompt + Discord free_response","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#12790","number":12790},{"kind":"pr","value":"#12805","number":12805},{"kind":"pr","value":"#12750","number":12750},{"kind":"pr","value":"#55958","number":55958},{"kind":"pr","value":"#12790","number":12790},{"kind":"pr","value":"#12805","number":12805},{"kind":"pr","value":"#12750","number":12750},{"kind":"pr","value":"#55958","number":55958}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":19387,"title":"fix(agent): read model.max_tokens from config.yaml","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#19452","number":19452}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":19452,"title":"fix(agent): honor configured model max_tokens","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":19515,"title":"fix(agent): honor model.max_tokens in config.yaml","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#19452","number":19452},{"kind":"pr","value":"#21298","number":21298}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":19686,"title":"fix(transport): add default max_tokens for custom providers with tools","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"09ec26c66a130051412e747d49a7ea96f2862b57","number":0},{"kind":"pr","value":"#19452","number":19452},{"kind":"pr","value":"#19515","number":19515}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":21298,"title":"fix(agent): honor configured model.max_tokens (salvage #19452)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","run_agent.py","tests/gateway/test_agent_cache.py","tests/run_agent/test_run_agent.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -12251,6 +12251,7 @@ async def _run_process_watcher(self, watcher: dict) -> None:\n     # Add more here as new baked-at-construction config settings are added.\n     _CACHE_BUSTING_CONFIG_KEYS: tuple = (\n         (\"model\", \"context_length\"),\n+        (\"model\", \"max_tokens\"),\n         (\"compression\", \"enabled\"),\n         (\"compression\", \"threshold\"),\n         (\"compression\", \"target_ratio\"),"]},{"file":"run_agent.py","hunks":["@@ -1901,8 +1901,35 @@ def __init__(\n                 _aux_context_config = None\n         self._aux_compression_context_length_config = _aux_context_config\n \n-        # Read explicit context_length override from model config\n+        # Read explicit model output-token override from config when the\n+        # caller did not pass one directly.\n         _model_cfg = _agent_cfg.get(\"model\", {})\n+        if self.max_tokens is None and isinstance(_model_cfg, dict):\n+            _config_max_tokens = [redacted:secret-assignment]\"max_tokens\")\n+            if _config_max_tokens is not None:\n+                try:\n+                    if isinstance(_config_max_tokens, bool):\n+                        raise ValueError\n+                    _parsed_max_tokens = [redacted:secret-assignment]\n+                    if _parsed_max_tokens <= 0:\n+                        raise ValueError\n+                    self.max_tokens = [redacted:secret-assignment]\n+                except (TypeError, ValueError):\n+                    logger.warning(\n+                        \"Invalid model.max_tokens in config.yaml: %r — \"\n+                        \"must be a positive integer (e.g. 4096). \"\n+                        \"Falling back to provider default.\",\n+                        _config_max_tokens,\n+                    )\n+                    print(\n+                        f\"\\n⚠ Invalid model.max_tokens in config.yaml: {_config_max_tokens!r}\\n\"\n+                        f\"  Must be a positive integer (e.g. 4096).\\n\"\n+                        f\"  Falling back to provider default.\\n\",\n+                        file=sys.stderr,\n+                    )\n+        self._session_init_model_config[\"max_tokens\"] = self.max_tokens\n+\n+        # Read explicit context_length override from model config\n         if isinstance(_model_cfg, dict):\n             _config_context_length = _model_cfg.get(\"context_length\")\n         else:"]},{"file":"tests/gateway/test_agent_cache.py","hunks":["@@ -127,6 +127,21 @@ def test_context_length_change_busts_cache(self):\n         )\n         assert sig1 != sig2\n \n+    def test_max_tokens_change_busts_cache(self):\n+        \"\"\"Editing model.max_tokens in config must produce a new signature.\"\"\"\n+        from gateway.run import GatewayRunner\n+\n+        runtime = {\"api_key\": \"k\", \"base_url\": \"u\", \"provider\": \"p\"}\n+        sig1 = GatewayRunner._agent_config_signature(\n+            \"m\", runtime, [], \"\",\n+            cache_keys={\"model.max_tokens\": 4096},\n+        )\n+        sig2 = GatewayRunner._agent_config_signature(\n+            \"m\", runtime, [], \"\",\n+            cache_keys={\"model.max_tokens\": 8192},\n+        )\n+        assert sig1 != sig2\n+\n     def test_compression_threshold_change_busts_cache(self):\n         from gateway.run import GatewayRunner\n ","@@ -195,9 +210,16 @@ def test_reads_model_context_length(self):\n         from gateway.run import GatewayRunner\n \n         out = GatewayRunner._extract_cache_busting_config(\n-            {\"model\": {\"context_length\": 272_000, \"provider\": \"openrouter\"}}\n+            {\n+                \"model\": {\n+                    \"context_length\": 272_000,\n+                    \"max_tokens\": 4096,\n+                    \"provider\": \"openrouter\",\n+                }\n+            }\n         )\n         assert out[\"model.context_length\"] == 272_000\n+        assert out[\"model.max_tokens\"] == 4096\n \n     def test_reads_compression_subkeys(self):\n         from gateway.run import GatewayRunner"]},{"file":"tests/run_agent/test_run_agent.py","hunks":["@@ -724,6 +724,56 @@ def test_prompt_caching_cache_ttl_custom_1h(self):\n             )\n             assert a._cache_ttl == \"1h\"\n \n+    def test_model_max_tokens_from_config(self):\n+        \"\"\"model.max_tokens config populates the chat-completions request cap.\"\"\"\n+        with (\n+            patch(\"run_agent.get_tool_definitions\", return_value=_make_tool_defs(\"terminal\")),\n+            patch(\"run_agent.check_toolset_requirements\", return_value={}),\n+            patch(\"run_agent.OpenAI\"),\n+            patch(\n+                \"hermes_cli.config.load_config\",\n+                return_value={\"model\": {\"max_tokens\": 4096}},\n+            ),\n+        ):\n+            a = AIAgent(\n+                api_key=\"[redacted:secret-assignment]\",\n+                provider=\"custom\",\n+                model=\"claude-opus-4-6-thinking\",\n+                base_url=\"http://proxy.example/v1\",\n+                quiet_mode=True,\n+                skip_context_files=True,\n+                skip_memory=True,\n+            )\n+\n+            kwargs = a._build_api_kwargs([{\"role\": \"user\", \"content\": \"Hi\"}])\n+\n+        assert a.max_tokens == 4096\n+        assert kwargs[\"max_tokens\"] == 4096\n+\n+    def test_constructor_max_tokens_wins_over_config(self):\n+        \"\"\"Explicit constructor max_tokens keeps programmatic callers stable.\"\"\"\n+        with (\n+            patch(\"run_agent.get_tool_definitions\", return_value=[]),\n+            patch(\"run_agent.check_toolset_requirements\", return_value={}),\n+            patch(\"run_agent.OpenAI\"),\n+            patch(\n+                \"hermes_cli.config.load_config\",\n+                return_value={\"model\": {\"max_tokens\": 4096}},\n+            ),\n+        ):\n+            a = AIAgent(\n+                api_key=\"[redacted:secret-assignment]\",\n+                provider=\"custom\",\n+                model=\"claude-opus-4-6-thinking\",\n+                base_url=\"http://proxy.example/v1\",\n+                max_tokens=8192,\n+                quiet_mode=True,\n+                skip_context_files=True,\n+                skip_memory=True,\n+            )\n+\n+        assert a.max_tokens == 8192\n+\n     def test_prompt_caching_cache_ttl_invalid_falls_back(self):\n         \"\"\"Non-Anthropic TTL values keep default 5m without raising.\"\"\"\n         with (\n"]}]},{"kind":"pr","number":55958,"title":"fix(anthropic+feishu): model-gate max_tokens fallback; wire Feishu channel_prompt","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/chat_completion_helpers.py","plugins/platforms/feishu/adapter.py","tests/gateway/test_feishu_channel_prompts.py"],"fix_hunks":[{"file":"agent/chat_completion_helpers.py","hunks":["@@ -741,14 +741,26 @@ def build_api_kwargs(agent, api_messages: list) -> dict:\n     if agent.provider_data_collection:\n         _prefs[\"data_collection\"] = agent.provider_data_collection\n \n-    # Claude max-output override on aggregators\n+    # Anthropic-compatible max-output fallback (last resort only — applied in\n+    # build_kwargs *after* ephemeral/user/profile max_tokens, never overriding\n+    # an explicit value).  Model-gated, not URL-gated: any chat-completions\n+    # proxy serving a Claude/MiniMax/Qwen3 model needs max_tokens, because the\n+    # Anthropic Messages API treats it as mandatory and proxies that omit it\n+    # (AWS Bedrock, NVIDIA, LiteLLM, vLLM, corporate gateways) default as low\n+    # as 4096 output tokens — easily exhausted by thinking + large tool calls\n+    # like write_file/patch.  OpenRouter/Nous were the only routes covered\n+    # before; gating on _ANTHROPIC_OUTPUT_LIMITS membership covers them all.\n     _ant_max = None\n-    if (_is_or or _is_nous) and \"claude\" in (agent.model or \"\").lower():\n-        try:\n-            from agent.anthropic_adapter import _get_anthropic_max_output\n+    try:\n+        from agent.anthropic_adapter import (\n+            _get_anthropic_max_output,\n+            _ANTHROPIC_OUTPUT_LIMITS,\n+        )\n+        _model_norm = (agent.model or \"\").lower().replace(\".\", \"-\")\n+        if any(key in _model_norm for key in _ANTHROPIC_OUTPUT_LIMITS):\n             _ant_max = _get_anthropic_max_output(agent.model)\n-        except Exception:\n-            pass\n+    except Exception:\n+        pass\n \n     # Qwen session metadata\n     _qwen_meta = None"]},{"file":"plugins/platforms/feishu/adapter.py","hunks":["@@ -2892,6 +2892,7 @@ async def _handle_reaction_event(self, event_type: str, data: Any) -> None:\n             source=source,\n             raw_message=data,\n             message_id=message_id,\n+            channel_prompt=self._resolve_channel_prompt(chat_id),\n             timestamp=datetime.now(),\n         )\n         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:\n             source=source,\n             raw_message=data,\n             message_id=token or str(uuid.uuid4()),\n+            channel_prompt=self._resolve_channel_prompt(chat_id),\n             timestamp=datetime.now(),\n         )\n         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:\n     # Inbound processing pipeline\n     # =========================================================================\n \n+    def _resolve_channel_prompt(self, chat_id: str, parent_id: str | None = None) -> str | None:\n+        \"\"\"Resolve a Feishu per-channel system prompt.\n+\n+        Mirrors the Discord/Slack behaviour so ``channel_prompts: {<chat_id>:\n+        \"<prompt>\"}`` in ``PlatformConfig.extra`` is honoured for Feishu chats\n+        instead of being silently ignored.\n+        \"\"\"\n+        from gateway.platforms.base import resolve_channel_prompt\n+        _config = getattr(self, \"config\", None)\n+        _extra = getattr(_config, \"extra\", None) or {}\n+        return resolve_channel_prompt(_extra, chat_id, parent_id)\n+\n     async def _process_inbound_message(\n         self,\n         *,","@@ -3233,6 +3247,7 @@ async def _process_inbound_message(\n             media_types=media_types,\n             reply_to_message_id=reply_to_message_id,\n             reply_to_text=reply_to_text,\n+            channel_prompt=self._resolve_channel_prompt(chat_id, thread_id or None),\n             timestamp=datetime.now(),\n         )\n         await self._dispatch_inbound_event(normalized)"]},{"file":"tests/gateway/test_feishu_channel_prompts.py","hunks":["@@ -0,0 +1,88 @@\n+\"\"\"Tests for Feishu per-channel prompt resolution.\n+\n+Feishu previously ignored ``channel_prompts`` config (unlike Discord/Slack).\n+These tests verify that ``_resolve_channel_prompt`` reads the adapter's\n+``config.extra`` and that the resolved prompt is attached to the dispatched\n+``MessageEvent`` for the inbound, reaction, and card-action paths.\n+\"\"\"\n+\n+import asyncio\n+import json\n+from types import SimpleNamespace\n+from unittest.mock import AsyncMock, Mock\n+\n+from gateway.config import PlatformConfig\n+\n+\n+def _build_adapter(extra=None):\n+    from plugins.platforms.feishu.adapter import FeishuAdapter\n+\n+    adapter = FeishuAdapter.__new__(FeishuAdapter)\n+    adapter.config = PlatformConfig(extra=extra or {})\n+    adapter._bot_open_id = \"ou_bot\"\n+    adapter._bot_user_id = \"\"\n+    adapter._bot_name = \"Hermes\"\n+    adapter._download_feishu_message_resources = AsyncMock(return_value=([], []))\n+    adapter._fetch_message_text = AsyncMock(return_value=None)\n+    adapter.get_chat_info = AsyncMock(return_value={\"name\": \"Test Chat\"})\n+    adapter._resolve_sender_profile = AsyncMock(\n+        return_value={\"user_id\": \"u1\", \"user_name\": \"Alice\", \"user_id_alt\": None}\n+    )\n+    adapter._resolve_source_chat_type = Mock(return_value=\"group\")\n+    adapter.build_source = Mock(return_value=SimpleNamespace(thread_id=None))\n+    adapter._dispatch_inbound_event = AsyncMock()\n+    return adapter\n+\n+\n+def _run_inbound(adapter, chat_id=\"oc_chat\"):\n+    message = SimpleNamespace(\n+        content=json.dumps({\"text\": \"plain message\"}),\n+        message_type=\"text\",\n+        message_id=\"m\",\n+        mentions=[],\n+        chat_id=chat_id,\n+        parent_id=None,\n+        upper_message_id=None,\n+        thread_id=None,\n+    )\n+    asyncio.run(\n+        adapter._process_inbound_message(\n+            data=message, message=message, sender_id=None, chat_type=\"group\", message_id=\"m\",\n+        )\n+    )\n+    return adapter._dispatch_inbound_event.call_args.args[0]\n+\n+\n+def test_resolve_channel_prompt_exact_match():\n+    adapter = _build_adapter({\"channel_prompts\": {\"oc_chat\": \"Be terse.\"}})\n+    assert adapter._resolve_channel_prompt(\"oc_chat\") == \"Be terse.\"\n+\n+\n+def test_resolve_channel_prompt_parent_fallback():\n+    adapter = _build_adapter({\"channel_prompts\": {\"oc_parent\": \"Inherit me.\"}})\n+    assert adapter._resolve_channel_prompt(\"oc_thread\", \"oc_parent\") == \"Inherit me.\"\n+\n+\n+def test_resolve_channel_prompt_no_match_returns_none():\n+    adapter = _build_adapter({\"channel_prompts\": {\"oc_other\": \"Nope.\"}})\n+    assert adapter._resolve_channel_prompt(\"oc_chat\") is None\n+\n+\n+def test_resolve_channel_prompt_missing_config_is_safe():\n+    # __new__ adapter without a config attribute (defensive getattr path).\n+    from plugins.platforms.feishu.adapter import FeishuAdapter\n+\n+    bare = FeishuAdapter.__new__(FeishuAdapter)\n+    assert bare._resolve_channel_prompt(\"oc_chat\") is None\n+\n+\n+def test_inbound_event_carries_channel_prompt():\n+    adapter = _build_adapter({\"channel_prompts\": {\"oc_chat\": \"Feishu role prompt.\"}})\n+    event = _run_inbound(adapter, chat_id=\"oc_chat\")\n+    assert event.channel_prompt == \"Feishu role prompt.\"\n+\n+\n+def test_inbound_event_no_prompt_when_unconfigured():\n+    adapter = _build_adapter({\"channel_prompts\": {\"oc_other\": \"Different chat.\"}})\n+    event = _run_inbound(adapter, chat_id=\"oc_chat\")\n+    assert event.channel_prompt is None\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}