Feishu channel_prompt wiring and None-guard fixes

NousResearch/hermes-agent · cluster 12805 · gateway

Data as of

Nothing open on GitHub. Fix merged in #55958. Check it covers your case.

The short answer

0open issues
0open PRs
5items grouped
Known?
Yes. 5 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #55958. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and PRs center on the Feishu adapter missing channel_prompt resolution that other adapters (Discord, Slack) already have, plus a related AttributeError when event.channel_prompt is None. Some members also touch Anthropic max_tokens fallback and Discord free_response as adjacent fixes in the same changesets.

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.

  • #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

2 PRs closed without merging

From the close comments: done elsewhere 2

  • #12811 fix: Anthropic max_tokens + Feishu channel_prompt + Discord free_response · done elsewhere → #12790, #12805, #12750, #55958
  • #23693 fix: guard event.channel_prompt access against None event · done elsewhere → 36bfe3a, 90a6336
Closed issues · 2
  • Closed#12805Feishu adapter missing channel_prompt resolution (unlike Discord/Slack)
  • Closed#23682AttributeError: 'NoneType' object has no 'channel_prompt' attribute
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.34: 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
gateway
Merged PRs
#55958
Closed issues
2
Attempts
2 PRs closed without merging
Fix link checked
1 Jul 2026

For your agent

The same facts, typed.