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

0open issues
0open PRs
9items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #21298 fix(agent): honor configured model.max_tokens (salvage #19452)

    merged to main

    files: gateway/run.py, run_agent.py, tests/gateway/test_agent_cache.py, tests/run_agent/test_run_agent.py

    Show 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

    merged to main

    files: agent/chat_completion_helpers.py, plugins/platforms/feishu/adapter.py, tests/gateway/test_feishu_channel_prompts.py

    Show 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
  • Closed#12790[Bug]: Anthropic max_tokens fallback only fires for OpenRouter/Nous — silently skipped for Bedrock, NVIDIA, LiteLLM, and every other chat-completions proxy
  • Closed#19360[Bug] Missing `max_tokens` in API request body causes HTTP 400 when tools are used with custom OpenAI-compatible proxy
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.00 means 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

At a glance

Subsystem
agent
Merged PRs
#21298, #55958
Closed issues
2
Attempts
5 PRs closed without merging
Fix link checked
1 Jul 2026

For your agent

The same facts, typed.