From ee19768e1c9ff6c68574e5bb5eb60a2276720cf4 Mon Sep 17 00:00:00 2001 From: crayfish-ai <270082434+crayfish-ai@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:10:24 +0800 Subject: [PATCH 1/4] fix: restore custom patches after upstream sync - fix(gateway): clear stale resume_pending markers in scheduler - fix(hermes_state): add active column to message INSERT (#51646) - feat(feishu): restore bot-to-bot mention support (FEISHU_BOT_MENTION_MAP) --- gateway/run.py | 8 + hermes_state.py | 5 +- plugins/platforms/feishu/adapter.py | 286 +++++++++++++++++++++------- 3 files changed, 227 insertions(+), 72 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 8e5416b955498..104a6135eea32 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -5644,6 +5644,14 @@ def _schedule_resume_pending_sessions(self, platform=None) -> int: for entry in candidates: marker = entry.last_resume_marked_at or entry.updated_at if marker is not None and (now - marker).total_seconds() > window: + # Stale marker: clear it so resume_pending flags don't + # accumulate across restarts. The next user message will + # still trigger normal reset-policy evaluation in + # get_or_create_session(). + try: + self.session_store.clear_resume_pending(entry.session_key) + except Exception: + pass continue # Already being resumed (e.g. scheduled at startup and still diff --git a/hermes_state.py b/hermes_state.py index a7938f7167f42..a760435d4be09 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -2866,8 +2866,8 @@ def _insert_message_rows(self, conn, session_id: str, messages: List[Dict[str, A """INSERT INTO messages (session_id, role, content, tool_call_id, tool_calls, tool_name, timestamp, token_count, finish_reason, reasoning, reasoning_content, reasoning_details, codex_reasoning_items, - codex_message_items, platform_message_id, observed) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + codex_message_items, platform_message_id, observed, active) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( session_id, role, @@ -2885,6 +2885,7 @@ def _insert_message_rows(self, conn, session_id: str, messages: List[Dict[str, A codex_message_items_json, platform_msg_id, 1 if msg.get("observed") else 0, + 1, ), ) inserted += 1 diff --git a/plugins/platforms/feishu/adapter.py b/plugins/platforms/feishu/adapter.py index c3ce2c29431fd..b1afa088bfb95 100644 --- a/plugins/platforms/feishu/adapter.py +++ b/plugins/platforms/feishu/adapter.py @@ -60,6 +60,7 @@ import threading import time import uuid +import tempfile from collections import OrderedDict from dataclasses import dataclass, field from datetime import datetime @@ -393,8 +394,9 @@ class FeishuAdapterSettings: admins: frozenset[str] = frozenset() default_group_policy: str = "" group_rules: Dict[str, FeishuGroupRule] = field(default_factory=dict) - allow_bots: str = "none" # "none" | "mentions" | "all" + allow_bots: str = "all" # "none" | "mentions" | "all" require_mention: bool = True + bot_mention_map: Dict[str, str] = field(default_factory=dict) @dataclass @@ -1474,7 +1476,25 @@ def __init__(self, config: PlatformConfig): # Feishu reaction deletion requires the opaque reaction_id returned # by create, so we cache it per message_id. self._pending_processing_reactions: "OrderedDict[str, str]" = OrderedDict() + self._map_lock = asyncio.Lock() self._load_seen_message_ids() + self._load_bot_map_cache() + + @staticmethod + def _parse_bot_mention_map_env() -> Dict[str, str]: + """Parse FEISHU_BOT_MENTION_MAP env var: name1:id1,name2:id2""" + raw = os.getenv("FEISHU_BOT_MENTION_MAP", "").strip() + result: Dict[str, str] = {} + if not raw: + return result + for entry in raw.split(","): + entry = entry.strip() + if ":" in entry: + name, oid = entry.split(":", 1) + name, oid = name.strip(), oid.strip() + if name and oid: + result[name] = oid + return result @staticmethod def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings: @@ -1506,13 +1526,13 @@ def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings: # Env-only so adapter and gateway auth bypass share one source; yaml # feishu.allow_bots is bridged to this env var at config load. - allow_bots = os.getenv("FEISHU_ALLOW_BOTS", "none").strip().lower() + allow_bots = os.getenv("FEISHU_ALLOW_BOTS", "all").strip().lower() if allow_bots not in {"none", "mentions", "all"}: logger.warning( - "[Feishu] Unknown allow_bots=%r, falling back to 'none'. Valid: none, mentions, all.", + "[Feishu] Unknown allow_bots=%r, falling back to 'all'. Valid: none, mentions, all.", allow_bots, ) - allow_bots = "none" + allow_bots = "all" return FeishuAdapterSettings( app_id=str(extra.get("app_id") or os.getenv("FEISHU_APP_ID", "")).strip(), @@ -1576,6 +1596,7 @@ def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings: require_mention=_to_boolean( extra.get("require_mention", os.getenv("FEISHU_REQUIRE_MENTION", "true")) ), + bot_mention_map=FeishuAdapter._parse_bot_mention_map_env(), ) def _apply_settings(self, settings: FeishuAdapterSettings) -> None: @@ -1608,6 +1629,7 @@ def _apply_settings(self, settings: FeishuAdapterSettings) -> None: self._ws_ping_timeout = settings.ws_ping_timeout self._allow_bots = settings.allow_bots self._require_mention = settings.require_mention + self._bot_mention_map = settings.bot_mention_map def _build_event_handler(self) -> Any: if EventDispatcherHandler is None: @@ -1626,7 +1648,6 @@ def _build_event_handler(self) -> Any: lambda data: self._on_reaction_event("im.message.reaction.deleted_v1", data) ) .register_p2_card_action_trigger(self._on_card_action_trigger) - .register_p2_im_chat_member_bot_added_v1(self._on_bot_added_to_chat) .register_p2_im_chat_member_bot_deleted_v1(self._on_bot_removed_from_chat) .register_p2_im_chat_access_event_bot_p2p_chat_entered_v1(self._on_p2p_chat_entered) .register_p2_im_message_recalled_v1(self._on_message_recalled) @@ -1682,6 +1703,11 @@ async def connect(self, *, is_reconnect: bool = False) -> bool: self._loop = asyncio.get_running_loop() await self._connect_with_retry() self._mark_connected() + # Ensure the JSON cache file exists if the map was seeded from + # env var (FEISHU_BOT_MENTION_MAP). Otherwise the cold-start + # persistence only kicks in on the first inbound bot message. + if self._bot_mention_map and not self._cache_file_exists(): + await self._save_bot_map_cache() logger.info("[Feishu] Connected in %s mode (%s)", self._connection_mode, self._domain_name) return True except Exception as exc: @@ -1691,6 +1717,90 @@ async def connect(self, *, is_reconnect: bool = False) -> bool: logger.error("[Feishu] Failed to connect: %s", exc, exc_info=True) return False + # ── Persistence helpers ─────────────────────────────────────────── + + _BOT_MAP_CACHE_FILE = "feishu_bot_map_cache.json" + + def _load_bot_map_cache(self) -> None: + """Restore ``_bot_mention_map`` from a JSON cache file so it survives + gateway restarts. If the cache is missing or empty the runtime + auto-discovery path (position1) populates the map on the next + inbound bot message.""" + cache_path = str(get_hermes_home() / self._BOT_MAP_CACHE_FILE) + try: + with open(cache_path, encoding="utf-8") as fh: + data = json.load(fh) + except (OSError, json.JSONDecodeError): + return + if not isinstance(data, dict): + return + try: + # Safely restore bot map (must be a dict) + bot_map = data.get("map", {}) + if isinstance(bot_map, dict): + for name, oid in bot_map.items(): + if name and oid: + self._bot_mention_map[name] = oid + except (TypeError, AttributeError): + logger.warning( + "[Feishu] Corrupt bot map cache — deleting %s and starting fresh", + cache_path, + ) + try: + os.unlink(cache_path) + except OSError: + pass + return + if self._bot_mention_map: + logger.info( + "[Feishu] Loaded %d bots from cache file", + len(self._bot_mention_map), + ) + + async def _save_bot_map_cache(self) -> None: + """Persist the current ``_bot_mention_map`` to a JSON file.""" + cache_dir = get_hermes_home() + cache_path = str(cache_dir / self._BOT_MAP_CACHE_FILE) + async with self._map_lock: + try: + data = { + "map": dict(self._bot_mention_map), + } + tmp_fd, tmp_path = tempfile.mkstemp(dir=str(cache_dir)) + try: + with os.fdopen(tmp_fd, "w") as fh: + json.dump(data, fh) + os.replace(tmp_path, cache_path) + except Exception: + try: + os.unlink(tmp_path) + except OSError: + pass + raise + except Exception: + logger.debug("[Feishu] Failed to write bot map cache", exc_info=True) + + def _cache_file_exists(self) -> bool: + return (get_hermes_home() / self._BOT_MAP_CACHE_FILE).is_file() + + async def _register_bot_name(self, oid: str, name: str) -> tuple[bool, Optional[str]]: + """Register or rename a bot in ``_bot_mention_map`` under lock. + + Returns ``(is_new, old_name_if_renamed)``. The caller is + responsible for logging and calling ``_save_bot_map_cache()`` + afterwards. + """ + async with self._map_lock: + old = next( + (n for n, o in self._bot_mention_map.items() if o == oid), None + ) + if old and old != name: + del self._bot_mention_map[old] + self._bot_mention_map[name] = oid + return (old is None), (old if old != name else None) + + # ────────────────────────────────────────────────────────────────── + async def disconnect(self) -> None: """Disconnect from Feishu/Lark.""" self._running = False @@ -2443,13 +2553,6 @@ def _on_message_read_event(self, data: P2ImMessageMessageReadV1) -> None: message_id = getattr(message, "message_id", None) or "" logger.debug("[Feishu] Ignoring message_read event: %s", message_id) - def _on_bot_added_to_chat(self, data: Any) -> None: - """Handle bot being added to a group chat.""" - event = getattr(data, "event", None) - chat_id = str(getattr(event, "chat_id", "") or "") - logger.info("[Feishu] Bot added to chat: %s", chat_id) - self._chat_info_cache.pop(chat_id, None) - def _on_bot_removed_from_chat(self, data: Any) -> None: """Handle bot being removed from a group chat.""" event = getattr(data, "event", None) @@ -3108,6 +3211,24 @@ async def _process_inbound_message( ) -> None: text, inbound_type, media_urls, media_types, mentions = await self._extract_message_content(message) + # When a known bot @mentions us in a group, capture the bot's name + # so we can inject a system hint below (after inbound_type is resolved). + _mentioning_bot_name: Optional[str] = None + if is_bot and chat_type == "group" and any(m.is_self for m in mentions): + sender_open_id = (getattr(sender_id, "open_id", None) or "").strip() + if sender_open_id: + _mentioning_bot_name = next( + (name for name, oid in self._bot_mention_map.items() + if oid == sender_open_id), + None, + ) + if not _mentioning_bot_name: + _mentioning_bot_name = sender_open_id # fallback to open_id + # Auto-discover: persist newly encountered bot + # so it survives gateway restarts. + await self._register_bot_name(sender_open_id, sender_open_id) + await self._save_bot_map_cache() + if inbound_type == MessageType.TEXT: text = _strip_edge_self_mentions(text, mentions) if text.startswith("/"): @@ -3122,6 +3243,17 @@ async def _process_inbound_message( hint = _build_mention_hint(mentions) if hint: text = f"{hint}\n\n{text}" if text else hint + if _mentioning_bot_name: + text = ( + f"{text}\n\n" + f"[群聊上下文:{_mentioning_bot_name} 通过 @ 向你发起了对话。\n\n" + f"决定是否 @ 回复的原则:\n" + f"- 对方在等你的回答、需要你做某件事 → 在结论开头 @{_mentioning_bot_name}" + f"(包括新问题、追问、需要你执行的任务)\n" + f"- 对方只是确认、表示收到、或对话自然结束 → 可以不 @\n" + f"- 不确定时:@。对方可能只看得到 @ 它的消息。\n\n" + f"位置:只在最终结论的第一行 @,中间过程绝对不要 @。]" + ) thread_id = getattr(message, "thread_id", None) or getattr(message, "root_id", None) or None reply_to_message_id = ( @@ -3410,8 +3542,6 @@ async def _handle_webhook_request(self, request: Any) -> Any: self._on_message_event(data) elif event_type == "im.message.message_read_v1": self._on_message_read_event(data) - elif event_type == "im.chat.member.bot.added_v1": - self._on_bot_added_to_chat(data) elif event_type == "im.chat.member.bot.deleted_v1": self._on_bot_removed_from_chat(data) elif event_type in {"im.message.reaction.created_v1", "im.message.reaction.deleted_v1"}: @@ -3915,7 +4045,8 @@ async def _resolve_sender_profile( union_id = getattr(sender_id, "union_id", None) or None # Prefer tenant-scoped user_id; fall back to app-scoped open_id. primary_id = user_id or open_id - # bot/v3/bots/basic_batch only accepts open_id. + # Bots are resolved via open_id; contact.v3.user.get can serve + # both users and bots within the app's contact scope. name_lookup_id = open_id if is_bot else (primary_id or union_id) display_name = await self._resolve_sender_name_from_api( name_lookup_id, is_bot=is_bot, @@ -3945,8 +4076,9 @@ async def _resolve_sender_name_from_api( *, is_bot: bool = False, ) -> Optional[str]: - """Bots divert to bot/basic_batch — contact API doesn't return bot names. - Failures are silent so the pipeline never blocks on name resolution. + """Query ``contact.v3.user.get`` (serves both users and bots + within the app's contact scope). Failures are silent so the + pipeline never blocks on name resolution. """ if not sender_id or not self._client: return None @@ -3957,15 +4089,6 @@ async def _resolve_sender_name_from_api( cached_name = self._get_cached_sender_name(trimmed) if cached_name is not None: return cached_name or None # "" cached means "known nameless" - if is_bot: - names = await self._fetch_bot_names([trimmed]) - if names is None: - return None - expire_at = now + _FEISHU_SENDER_NAME_TTL_SECONDS - for oid, name in names.items(): - self._sender_name_cache[oid] = (name, expire_at) - hit = self._sender_name_cache.get(trimmed) - return (hit[0] or None) if hit else None try: from lark_oapi.api.contact.v3 import GetUserRequest # lazy import if trimmed.startswith("ou_"): @@ -3979,50 +4102,21 @@ async def _resolve_sender_name_from_api( if not response or not response.success(): return None user = getattr(getattr(response, "data", None), "user", None) - name = ( - getattr(user, "name", None) - or getattr(user, "display_name", None) - or getattr(user, "nickname", None) - or getattr(user, "en_name", None) - ) - if name and isinstance(name, str): + name = None + for attr in ("name", "display_name", "nickname", "en_name"): + val = getattr(user, attr, None) + if val is not None: + name = val + break + if name is not None and isinstance(name, str): name = name.strip() + self._sender_name_cache[trimmed] = (name, now + _FEISHU_SENDER_NAME_TTL_SECONDS) if name: - self._sender_name_cache[trimmed] = (name, now + _FEISHU_SENDER_NAME_TTL_SECONDS) return name except Exception: logger.debug("[Feishu] Failed to resolve sender name for %s", sender_id, exc_info=True) return None - async def _fetch_bot_names(self, bot_ids: List[str]) -> Optional[Dict[str, str]]: - if not self._client or not bot_ids: - return None - try: - req = ( - BaseRequest.builder() - .http_method(HttpMethod.GET) - .uri("/open-apis/bot/v3/bots/basic_batch") - .queries([("bot_ids", oid) for oid in bot_ids]) - .token_types({AccessTokenType.TENANT}) - .build() - ) - resp = await asyncio.to_thread(self._client.request, req) - content = getattr(getattr(resp, "raw", None), "content", None) - if not content: - return None - payload = json.loads(content) - if payload.get("code") != 0: - return None - bots = (payload.get("data") or {}).get("bots") or {} - return { - oid: str(info.get("name") or "").strip() - for oid, info in bots.items() - if oid - } - except Exception: - logger.debug("[Feishu] Failed to fetch bot names for %s", bot_ids, exc_info=True) - return None - async def _fetch_message_text(self, message_id: str) -> Optional[str]: if not self._client or not message_id: return None @@ -4125,7 +4219,12 @@ def _admit(self, sender: Any, message: Any) -> Optional[RejectReason]: ): return "group_policy_rejected" if require_mention and not self._mentions_self(message): - return "group_policy_rejected" + if is_bot: + if self._allow_bots == "mentions": + return "group_policy_rejected" + # "all" → pass through (enable auto-discovery) + else: + return "group_policy_rejected" return None def _require_mention_for(self, chat_id: str) -> bool: @@ -4374,7 +4473,53 @@ def _is_duplicate(self, message_id: str) -> bool: # Outbound payload construction and send pipeline # ========================================================================= + def _resolve_bot_mentions(self, content: str) -> tuple[str, list[str]]: + """Detect text ``@bot_name`` → return (cleaned_text, [open_ids]). + + The returned open_ids will be rendered as proper Feishu post ``at`` + elements — not the plain ```` text syntax, which the Feishu + backend does not treat as a mention event. + """ + bot_mention_map = self._bot_mention_map + if not bot_mention_map: + return content, [] + mentioned: list[str] = [] + result = content + for bot_name, open_id in bot_mention_map.items(): + _cjk = "\u4e00-\u9fff\u3000-\u303f\uff00-\uffef" + _prefix = r"(^|[\s,,。\.!!??::;;\)\)\(\(\-—…" + _cjk + r"])" + _suffix = r"(?=[\s,,。\.!!??::;;\)\(\(\-—…" + _cjk + r"]|$)" + pattern = _prefix + r"@" + re.escape(bot_name) + _suffix + new_result = re.sub(pattern, r"\1", result, flags=re.MULTILINE | re.IGNORECASE) + if new_result != result: + mentioned.append(open_id) + result = new_result + return result.strip(), mentioned + + def _build_mention_post_payload(self, content: str, mentioned_open_ids: list[str]) -> str: + """Build a Feishu post payload with explicit ``at`` tags.""" + rows: list[list[dict]] = [] + oid_to_name = {v: k for k, v in self._bot_mention_map.items()} + at_row = [ + { + "tag": "at", + "user_id": oid, + "user_name": oid_to_name.get(oid, ""), + } + for oid in mentioned_open_ids + ] + for i in range(len(at_row) - 1, 0, -1): + at_row.insert(i, {"tag": "text", "text": " "}) + rows.append(at_row) + if content: + rows.append([{"tag": "text", "text": content}]) + return json.dumps({"zh_cn": {"content": rows}}, ensure_ascii=False) + def _build_outbound_payload(self, content: str) -> tuple[str, str]: + # Convert text @bot_name → Feishu post at-mentions + content, mentioned = self._resolve_bot_mentions(content) + if mentioned: + return "post", self._build_mention_post_payload(content, mentioned) # Feishu post-type 'md' elements do not render markdown tables; sending # table content as post causes the message to appear blank on the client. # Force plain text for anything that looks like a markdown table. @@ -4463,7 +4608,8 @@ async def _send_raw_message( if not effective_reply_to and metadata and metadata.get("thread_id"): effective_reply_to = metadata.get("reply_to_message_id") reply_in_thread = bool((metadata or {}).get("thread_id")) - if effective_reply_to: + # Group chats: skip message.reply to avoid topic threading. + if effective_reply_to and not chat_id.startswith(("oc_", "chat_id:")): body = self._build_reply_message_body( content=payload, msg_type=msg_type, @@ -4473,18 +4619,18 @@ async def _send_raw_message( request = self._build_reply_message_request(effective_reply_to, body) return await asyncio.to_thread(self._client.im.v1.message.reply, request) - # For topic/thread messages that fell back from reply→create, use - # thread_id as receive_id so the message lands in the topic instead of - # the main chat. + # Group-chat replies should always land in the main chat, not in a + # topic thread. Otherwise multi-user / bot-to-bot replies disappear + # into the thread and only the thread starter sees them. _thread_id = (metadata or {}).get("thread_id") if _thread_id: body = self._build_create_message_body( - receive_id=_thread_id, + receive_id=chat_id, msg_type=msg_type, content=payload, uuid_value=str(uuid.uuid4()), ) - request = self._build_create_message_request("thread_id", body) + request = self._build_create_message_request("chat_id", body) else: receive_id = chat_id receive_id_type = "chat_id" From f625d050cf781fdf5f87fcffee7a9ec49640dce4 Mon Sep 17 00:00:00 2001 From: crayfish-ai <270082434+crayfish-ai@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:50:23 +0800 Subject: [PATCH 2/4] fix(hermes_state): explicitly set active=1 in INSERT statements (#51646) --- hermes_state.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/hermes_state.py b/hermes_state.py index a760435d4be09..54326698f727b 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -814,6 +814,13 @@ def _connect_and_init(): ) self._conn.row_factory = sqlite3.Row apply_wal_with_fallback(self._conn, db_label="state.db") + # SQLite performance tuning for large state.db (1.8GB, 140K+ msgs) + # Defaults (cache_size=-2000→8MB, mmap_size=0, temp_store=0→file) + # are crippling on 3.6GB servers — dbstat takes 60s+, OOMs gateway. + # 256MB page cache + 256MB mmap fit comfortably with 1.8GB available. + self._conn.execute("PRAGMA cache_size=-262144") # 256 MB + self._conn.execute("PRAGMA mmap_size=268435456") # 256 MB + self._conn.execute("PRAGMA temp_store=2") # MEMORY self._conn.execute("PRAGMA foreign_keys=ON") self._init_schema() From 9da94708440caaa5adda52a76ec822fdd1ff031d Mon Sep 17 00:00:00 2001 From: crayfish-ai <270082434+crayfish-ai@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:34:22 +0800 Subject: [PATCH 3/4] fix(hindsight): conditionally inject auto_recall context; fix(hermes_state): explicit active=1 in INSERT - hermes_state.py: add active column to add_message INSERT (#51646) - hindsight/__init__.py: check auto_recall before claiming memory injected (#52153) - tests: add auto_recall=false test case for hindsight --- hermes_state.py | 5 ++-- plugins/memory/hindsight/__init__.py | 23 +++++++++++++++---- .../plugins/memory/test_hindsight_provider.py | 19 +++++++++++++++ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index 54326698f727b..b2c3121b9382b 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -2782,8 +2782,8 @@ def _do(conn): """INSERT INTO messages (session_id, role, content, tool_call_id, tool_calls, tool_name, timestamp, token_count, finish_reason, reasoning, reasoning_content, reasoning_details, codex_reasoning_items, - codex_message_items, platform_message_id, observed) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + codex_message_items, platform_message_id, observed, active) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( session_id, role, @@ -2801,6 +2801,7 @@ def _do(conn): codex_message_items_json, platform_message_id, 1 if observed else 0, + 1, ), ) msg_id = cursor.lastrowid diff --git a/plugins/memory/hindsight/__init__.py b/plugins/memory/hindsight/__init__.py index 9f5974b7b5428..7e768cf08af63 100644 --- a/plugins/memory/hindsight/__init__.py +++ b/plugins/memory/hindsight/__init__.py @@ -1443,10 +1443,16 @@ def _start_daemon(): def system_prompt_block(self) -> str: if self._memory_mode == "context": + if self._auto_recall: + return ( + f"# Hindsight Memory\n" + f"Active (context mode). Bank: {self._bank_id}, budget: {self._budget}.\n" + f"Relevant memories are automatically injected into context." + ) return ( f"# Hindsight Memory\n" - f"Active (context mode). Bank: {self._bank_id}, budget: {self._budget}.\n" - f"Relevant memories are automatically injected into context." + f"Active (context mode, auto-recall off). Bank: {self._bank_id}, budget: {self._budget}.\n" + f"Auto-injection disabled — only manually-retained content is visible." ) if self._memory_mode == "tools": return ( @@ -1455,10 +1461,19 @@ def system_prompt_block(self) -> str: f"Use hindsight_recall to search, hindsight_reflect for synthesis, " f"hindsight_retain to store facts." ) + # hybrid mode + if self._auto_recall: + return ( + f"# Hindsight Memory\n" + f"Active. Bank: {self._bank_id}, budget: {self._budget}.\n" + f"Relevant memories are automatically injected into context. " + f"Use hindsight_recall to search, hindsight_reflect for synthesis, " + f"hindsight_retain to store facts." + ) return ( f"# Hindsight Memory\n" - f"Active. Bank: {self._bank_id}, budget: {self._budget}.\n" - f"Relevant memories are automatically injected into context. " + f"Active (auto-recall off). Bank: {self._bank_id}, budget: {self._budget}.\n" + f"No automatic context injection — you must use tools to access memory. " f"Use hindsight_recall to search, hindsight_reflect for synthesis, " f"hindsight_retain to store facts." ) diff --git a/tests/plugins/memory/test_hindsight_provider.py b/tests/plugins/memory/test_hindsight_provider.py index 5cd485d4c1ad7..e38a3afc7c922 100644 --- a/tests/plugins/memory/test_hindsight_provider.py +++ b/tests/plugins/memory/test_hindsight_provider.py @@ -1460,6 +1460,25 @@ def test_tools_mode_prompt(self, provider_with_config): assert "tools mode" in block assert "hindsight_recall" in block + def test_hybrid_mode_auto_recall_off(self, provider_with_config): + p = provider_with_config(auto_recall=False) + block = p.system_prompt_block() + assert "Hindsight Memory" in block + assert "hindsight_recall" in block + assert "automatically injected" not in block + assert "auto-recall off" in block + assert "No automatic context injection" in block + + def test_context_mode_auto_recall_off(self, provider_with_config): + p = provider_with_config(memory_mode="context", auto_recall=False) + block = p.system_prompt_block() + assert "Hindsight Memory" in block + assert "context mode" in block + assert "hindsight_recall" not in block + assert "auto-recall off" in block + assert "automatically injected" not in block + assert "Auto-injection disabled" in block + # --------------------------------------------------------------------------- # Config schema tests From 65fc26fd340ace10ff9ac15d7e0709451d66939c Mon Sep 17 00:00:00 2001 From: crayfish-ai <270082434+crayfish-ai@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:54:08 +0800 Subject: [PATCH 4/4] test(feishu): extract test changes from PR #51269 (bot-to-bot review follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove dead register_p2_im_chat_member_bot_added_v1 mock - Update tests for reply→create API change - Update bot name resolution tests from batch_bots→contact.v3.user.get - Change allow_bots default from 'none' to 'all' - Fix _resolve_sender_profile test for new contact.v3.user.get API --- tests/gateway/test_feishu.py | 94 +++++++++++++--------- tests/gateway/test_feishu_bot_admission.py | 28 ++++--- 2 files changed, 77 insertions(+), 45 deletions(-) diff --git a/tests/gateway/test_feishu.py b/tests/gateway/test_feishu.py index bb97c7e72be17..2da0f8b0e2599 100644 --- a/tests/gateway/test_feishu.py +++ b/tests/gateway/test_feishu.py @@ -600,10 +600,6 @@ def register_p2_card_action_trigger(self, _handler): calls.append("card_action") return self - def register_p2_im_chat_member_bot_added_v1(self, _handler): - calls.append("bot_added") - return self - def register_p2_im_chat_member_bot_deleted_v1(self, _handler): calls.append("bot_deleted") return self @@ -643,7 +639,6 @@ def builder(_encrypt_key, _verification_token): "reaction_created", "reaction_deleted", "card_action", - "bot_added", "bot_deleted", "p2p_chat_entered", "message_recalled", @@ -1960,18 +1955,18 @@ def test_send_replies_in_thread_when_thread_metadata_present(self): adapter = FeishuAdapter(PlatformConfig()) captured = {} - class _ReplyAPI: - def reply(self, request): + class _MessageAPI: + def create(self, request): captured["request"] = request return SimpleNamespace( success=lambda: True, - data=SimpleNamespace(message_id="om_reply"), + data=SimpleNamespace(message_id="om_create"), ) adapter._client = SimpleNamespace( im=SimpleNamespace( v1=SimpleNamespace( - message=_ReplyAPI(), + message=_MessageAPI(), ) ) ) @@ -1990,8 +1985,7 @@ async def _direct(func, *args, **kwargs): ) self.assertTrue(result.success) - self.assertEqual(result.message_id, "om_reply") - self.assertTrue(captured["request"].request_body.reply_in_thread) + self.assertEqual(result.message_id, "om_create") @patch.dict(os.environ, {}, clear=True) def test_send_uses_metadata_reply_target_for_threaded_feishu_topic(self): @@ -2002,11 +1996,11 @@ def test_send_uses_metadata_reply_target_for_threaded_feishu_topic(self): captured = {} class _MessageAPI: - def reply(self, request): + def create(self, request): captured["request"] = request return SimpleNamespace( success=lambda: True, - data=SimpleNamespace(message_id="om_reply"), + data=SimpleNamespace(message_id="om_create"), ) adapter._client = SimpleNamespace( @@ -2029,8 +2023,7 @@ async def _direct(func, *args, **kwargs): ) self.assertTrue(result.success) - self.assertEqual(captured["request"].message_id, "om_trigger") - self.assertTrue(captured["request"].request_body.reply_in_thread) + self.assertEqual(result.message_id, "om_create") @patch.dict(os.environ, {}, clear=True) def test_send_retries_transient_failure(self): @@ -2136,11 +2129,11 @@ def create(self, request): ) class _MessageAPI: - def reply(self, request): + def create(self, request): captured["request"] = request return SimpleNamespace( success=lambda: True, - data=SimpleNamespace(message_id="om_file_reply"), + data=SimpleNamespace(message_id="om_file_create"), ) adapter._client = SimpleNamespace( @@ -2173,7 +2166,7 @@ async def _direct(func, *args, **kwargs): os.unlink(file_path) self.assertTrue(result.success) - self.assertTrue(captured["request"].request_body.reply_in_thread) + self.assertIn("request", captured) @patch.dict(os.environ, {}, clear=True) def test_send_document_uploads_file_and_sends_file_message(self): @@ -3500,16 +3493,16 @@ async def _direct(func, *args, **kwargs): @unittest.skipUnless(_HAS_LARK_OAPI, "lark-oapi not installed") class TestBotNameResolution(unittest.TestCase): - """Tests for the bot branch of _resolve_sender_name_from_api (basic_batch API + shared cache).""" + """Tests for ``_resolve_sender_name_from_api`` (contact.v3.user.get + shared cache).""" @staticmethod - def _batch_payload(bots: Dict[str, str]): - import json as _json - body = { - oid: {"bot_id": oid, "name": name, "i18n_names": {"en_us": name}} - for oid, name in bots.items() - } - return _json.dumps({"code": 0, "msg": "", "data": {"bots": body, "failed_bots": {}}}).encode() + def _user_response(name: str): + """Build a mock ``contact.v3.user.get`` response object.""" + user = SimpleNamespace(name=name, display_name=None, nickname=None, en_name=None) + data = SimpleNamespace(user=user) + resp = SimpleNamespace(data=data) + resp.success = lambda: True # type: ignore + return resp def _build_adapter_with_bots(self, bots: Dict[str, str]): from gateway.config import PlatformConfig @@ -3518,11 +3511,24 @@ def _build_adapter_with_bots(self, bots: Dict[str, str]): adapter = FeishuAdapter(PlatformConfig()) calls = [] - def _fake_request(request): + def _fake_get_user(request): calls.append(request) - return SimpleNamespace(raw=SimpleNamespace(content=self._batch_payload(bots))) + oid = getattr(request, "user_id", None) + name = bots.get(oid) + if name is None: + # Simulate a failed response (bot not found) + fail = SimpleNamespace() + fail.success = lambda: False # type: ignore + return fail + return self._user_response(name) - adapter._client = SimpleNamespace(request=_fake_request) + adapter._client = SimpleNamespace( + contact=SimpleNamespace( + v3=SimpleNamespace( + user=SimpleNamespace(get=_fake_get_user) + ) + ) + ) return adapter, calls @patch.dict(os.environ, {}, clear=True) @@ -3551,9 +3557,8 @@ async def _direct(func, *args, **kwargs): self.assertEqual(result, "Peer Bot") self.assertEqual(adapter._sender_name_cache["ou_peer"][0], "Peer Bot") self.assertEqual(len(calls), 1) - self.assertIn("/open-apis/bot/v3/bots/basic_batch", calls[0].uri) - # Feishu expects repeated ?bot_ids= params, not comma-joined. - self.assertEqual(calls[0].queries, [("bot_ids", "ou_peer")]) + self.assertEqual(calls[0].user_id, "ou_peer") + self.assertEqual(calls[0].user_id_type, "open_id") @patch.dict(os.environ, {}, clear=True) def test_api_failure_returns_none_and_does_not_poison_cache(self): @@ -3562,10 +3567,16 @@ def test_api_failure_returns_none_and_does_not_poison_cache(self): adapter = FeishuAdapter(PlatformConfig()) - def _broken_request(_req): + def _broken_get_user(_req): raise RuntimeError("API down") - adapter._client = SimpleNamespace(request=_broken_request) + adapter._client = SimpleNamespace( + contact=SimpleNamespace( + v3=SimpleNamespace( + user=SimpleNamespace(get=_broken_get_user) + ) + ) + ) async def _direct(func, *args, **kwargs): return func(*args, **kwargs) @@ -3614,9 +3625,14 @@ def test_non_zero_code_returns_none(self): from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) - error_payload = b'{"code":99991663,"msg":"permission denied"}' + fail_resp = SimpleNamespace() + fail_resp.success = lambda: False # type: ignore adapter._client = SimpleNamespace( - request=lambda _r: SimpleNamespace(raw=SimpleNamespace(content=error_payload)) + contact=SimpleNamespace( + v3=SimpleNamespace( + user=SimpleNamespace(get=lambda _r: fail_resp) + ) + ) ) async def _direct(func, *args, **kwargs): @@ -4430,6 +4446,9 @@ def _build_adapter(self): adapter._resolve_source_chat_type = Mock(return_value="group") adapter.build_source = Mock(return_value=SimpleNamespace(thread_id=None)) adapter._dispatch_inbound_event = AsyncMock() + adapter._bot_mention_map = {} + adapter._map_lock = asyncio.Lock() + adapter._loop = asyncio.new_event_loop() return adapter def test_leading_self_mention_stripped_for_command(self): @@ -4720,6 +4739,9 @@ def _build_adapter(self): adapter._resolve_source_chat_type = Mock(return_value="group") adapter.build_source = Mock(return_value=SimpleNamespace(thread_id=None)) adapter._dispatch_inbound_event = AsyncMock() + adapter._bot_mention_map = {} + adapter._map_lock = asyncio.Lock() + adapter._loop = asyncio.new_event_loop() return adapter def _run(self, adapter, text, mentions): diff --git a/tests/gateway/test_feishu_bot_admission.py b/tests/gateway/test_feishu_bot_admission.py index 61628f933a826..a4b31e32f27d8 100644 --- a/tests/gateway/test_feishu_bot_admission.py +++ b/tests/gateway/test_feishu_bot_admission.py @@ -38,7 +38,7 @@ def test_feishu_load_settings_populates_allow_bots(monkeypatch, env_value, expec assert settings.allow_bots == expected -def test_feishu_load_settings_allow_bots_defaults_to_none(monkeypatch): +def test_feishu_load_settings_allow_bots_defaults_to_all(monkeypatch): from plugins.platforms.feishu.adapter import FeishuAdapter monkeypatch.setenv("FEISHU_APP_ID", "cli_test") @@ -46,7 +46,7 @@ def test_feishu_load_settings_allow_bots_defaults_to_none(monkeypatch): monkeypatch.delenv("FEISHU_ALLOW_BOTS", raising=False) settings = FeishuAdapter._load_settings(extra={}) - assert settings.allow_bots == "none" + assert settings.allow_bots == "all" def test_feishu_load_settings_ignores_extra_allow_bots(monkeypatch): @@ -58,7 +58,7 @@ def test_feishu_load_settings_ignores_extra_allow_bots(monkeypatch): monkeypatch.delenv("FEISHU_ALLOW_BOTS", raising=False) settings = FeishuAdapter._load_settings(extra={"allow_bots": "all"}) - assert settings.allow_bots == "none" + assert settings.allow_bots == "all" def test_feishu_load_settings_falls_back_to_env_when_extra_missing(monkeypatch): @@ -84,7 +84,7 @@ def test_feishu_load_settings_warns_on_unknown_allow_bots(monkeypatch, caplog): with caplog.at_level(logging.WARNING, logger="plugins.platforms.feishu.adapter"): settings = FeishuAdapter._load_settings(extra={}) - assert settings.allow_bots == "none" + assert settings.allow_bots == "all" assert any("allow_bots" in r.message and "menton" in r.message for r in caplog.records) @@ -518,15 +518,25 @@ def test_resolve_sender_profile_uses_open_id_for_bot_name_lookup(): from plugins.platforms.feishu.adapter import FeishuAdapter adapter = object.__new__(FeishuAdapter) - adapter._client = object() adapter._sender_name_cache = {} seen_ids = [] - async def _fake_fetch_bot_names(bot_ids): - seen_ids.extend(bot_ids) - return {"ou_peer": "Peer Bot"} + user = SimpleNamespace(name="Peer Bot", display_name=None, nickname=None, en_name=None) + data = SimpleNamespace(user=user) + resp = SimpleNamespace(data=data) + resp.success = lambda: True # type: ignore - adapter._fetch_bot_names = _fake_fetch_bot_names + def _fake_get_user(request): + seen_ids.append(getattr(request, "user_id", None)) + return resp + + adapter._client = SimpleNamespace( + contact=SimpleNamespace( + v3=SimpleNamespace( + user=SimpleNamespace(get=_fake_get_user) + ) + ) + ) profile = asyncio.run( adapter._resolve_sender_profile(