mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
fix(anthropic+feishu): model-gate max_tokens fallback; wire Feishu channel_prompt
Two independent fixes salvaged from #12811 (closing it; one of its three bundled fixes — Discord free_response — is already on main). Anthropic max_tokens (#12790): the chat-completions max_tokens fallback only fired for OpenRouter/Nous URLs, so any other proxy serving a Claude model (AWS Bedrock, NVIDIA, LiteLLM, vLLM, corporate gateways) shipped requests with no max_tokens and inherited the proxy's low default (Bedrock: 4096), exhausting on thinking + large tool calls. Changed the gate in chat_completion_helpers.build_api_kwargs from URL-gated to model-gated: fires whenever the model matches an _ANTHROPIC_OUTPUT_LIMITS key. This also fixes a latent miss — the old 'claude' substring gate skipped MiniMax and Qwen3 even on OpenRouter. Remains a last-resort fallback (build_kwargs only applies it after ephemeral/user/profile max_tokens), so it never overrides an explicit value, and only touches the chat-completions transport (native Anthropic Messages API is a separate path). Feishu channel_prompt (#12805): the Feishu adapter never resolved channel_prompts config, unlike Discord/Slack, so per-channel role prompts were silently ignored. Added _resolve_channel_prompt() (delegating to the shared gateway.platforms.base.resolve_channel_prompt) and wired it into all three MessageEvent construction sites — inbound message, reaction routing, and card-action routing. Tests: tests/gateway/test_feishu_channel_prompts.py (6 cases) covering exact match, parent-thread fallback, no-match, missing-config safety, and event propagation.
This commit is contained in:
parent
20ca2d5759
commit
36bfe3a449
3 changed files with 121 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2892,6 +2892,7 @@ class FeishuAdapter(BasePlatformAdapter):
|
|||
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 @@ class FeishuAdapter(BasePlatformAdapter):
|
|||
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 @@ class FeishuAdapter(BasePlatformAdapter):
|
|||
# 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 @@ class FeishuAdapter(BasePlatformAdapter):
|
|||
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)
|
||||
|
|
|
|||
88
tests/gateway/test_feishu_channel_prompts.py
Normal file
88
tests/gateway/test_feishu_channel_prompts.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue