Files
hermes-agent/tools/yuanbao_tools.py
Teknium a5bd246865 Old pre-decomposition import paths are gone: plugin compat layer removed on schedule (#126164)
* refactor(plugins): remove the Sep 2026 decomposition compat layer on schedule

The PLUGIN-COMPAT layer (2776813df3 + d63e380324 + 0a5164cebe) kept pre-#102117 import paths
alive for external plugins until 2026-09-14. That window closed two weeks ago; since then the loader
has already been skipping plugins that use the old paths. This removes the layer itself:

- 328 appended `PLUGIN-COMPAT` blocks (lazy `__getattr__` pointer tables, re-exported third-party
  names, restored dead definitions) and the three re-export stub modules
  (gateway/startup_watchdog, hermes_cli/observability/relay_runtime, tools/environments/modal_utils)
- COMPAT_MANIFEST.md, compat_manifest.json, scripts/check_compat_pointers.py and its lint step
- the reporting surfaces: CLI banner notice, `hermes plugins compat`, the `hermes doctor` section,
  the post-update notice, the Desktop one-time dialog, the loader's pre-import skip and the
  `plugins.allow_deprecated_imports` escape hatch

An external plugin that still imports an old path now fails to load with its ImportError as the
reason in `hermes plugins list`, the same path as any broken plugin.

hermes_cli/plugin_compat.py stays as three inert stubs (compat_report, removal_in_effect,
summary_lines): an already-running pre-removal `hermes update` lazy-imports them after the checkout
swap (tests/compat/old_updater_surface.json).

In-tree fallout, both already dead: hermes_cli/setup.py::_check_espeak_ng (no callers; its
`shutil` came from a compat block) and gateway/config.py::SessionResetPolicy ("retained solely for
the scheduled plugin-compat window"). Two test_run_agent patches targeted the removed
`run_agent.handle_function_call` pointer; they now patch `model_tools.handle_function_call`, the
seam production reads, like every sibling test in that file.

* chore: retrigger CI (zero-job startup_failure phantom)

* test: drop resolution allowlist rows for the two deleted which() sites

hermes_cli/setup.py::_check_espeak_ng (dead) and tools/skillevaluator_scan.py::scanner_available
(a restored definition inside a PLUGIN-COMPAT block) no longer exist; the stale-row gate requires
their allowlist entries go with them.
2026-09-28 10:21:41 -07:00

496 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""yuanbao_tools.py - 元宝平台工具集 (the "hermes-yuanbao" toolset).
get_group_info / query_group_members / search_sticker / send_sticker / send_dm. Sticker
flow mirrors chatbot-web's sticker-search/sticker-send: the LLM should search_sticker for
a sticker_id (or pass the Chinese name), then send_sticker — never bare Unicode emoji.
The active adapter singleton lives in ``gateway.platforms.yuanbao.YuanbaoAdapter.get_active``.
"""
from __future__ import annotations
import functools
import logging
from contextlib import suppress
from pathlib import Path
from typing import Tuple
from tools.registry import registry, tool_result
logger = logging.getLogger(__name__)
_USER_TYPE_LABEL = {0: "unknown", 1: "user", 2: "yuanbao_ai", 3: "bot"}
MENTION_HINT = (
'To @mention a user, you MUST use the format: '
'space + @ + nickname + space (e.g. " @Alice ").'
)
# Image extensions for media dispatch (mirrors MessageSender.IMAGE_EXTS)
_IMAGE_EXTS = frozenset({".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"})
class _YbError(Exception):
"""Tool-level failure; ``payload`` is the error envelope returned to the model."""
def __init__(self, msg: str, **extra):
super().__init__(msg)
self.payload = {"success": False, "error": msg, **extra}
def _yb_tool(label: str):
"""Handler decorator: ``fn(args)`` → tool_result; ``_YbError`` → its envelope; else logged envelope."""
def deco(fn):
@functools.wraps(fn)
async def handler(args, **kw):
try:
return tool_result(await fn(args))
except _YbError as exc:
return tool_result(exc.payload)
except Exception as exc:
logger.exception("[yuanbao_tools] %s error", label)
return tool_result(_YbError(str(exc)).payload)
return handler
return deco
def _get_active_adapter():
"""Lazy import to avoid ImportError when gateway.platforms.yuanbao is unavailable."""
with suppress(ImportError):
from gateway.platforms.yuanbao import YuanbaoAdapter
return YuanbaoAdapter.get_active()
return None
def _adapter():
if (adapter := _get_active_adapter()) is None:
raise _YbError("Yuanbao adapter is not connected")
return adapter
def _session_env(name: str) -> str:
with suppress(Exception):
from gateway.session_context import get_session_env
return get_session_env(name, "")
return ""
def _nick(m: dict, default: str = "") -> str:
return m.get("nickname", m.get("nick_name", default))
async def _members(adapter, group_code: str) -> list:
raw = await adapter.get_group_member_list(group_code)
if raw is None:
raise _YbError("get_group_member_list returned None")
return raw.get("members", [])
async def _resolve_dm_recipient(adapter, group_code: str, name: str) -> Tuple[str, str]:
"""Resolve ``name`` to (user_id, nickname) via the group member list; >1 partial match raises
with ``candidates`` for disambiguation instead of guessing."""
if not group_code:
raise _YbError("group_code is required when user_id is not provided")
if not name:
raise _YbError("name is required when user_id is not provided")
filt = name.strip().lower()
matched = [m for m in await _members(adapter, group_code)
if filt in (m.get("nickname") or m.get("nick_name") or "").lower()]
if not matched:
raise _YbError(f'No member matching "{name}" found in group {group_code}.')
if len(matched) > 1:
raise _YbError(
f'Multiple members match "{name}". Please specify which one.',
candidates=[{"user_id": m.get("user_id", ""), "nickname": _nick(m)} for m in matched],
)
m = matched[0]
return m.get("user_id", ""), _nick(m, name)
@_yb_tool("get_group_info")
async def get_group_info(args) -> dict:
"""查询群基本信息(群名、群主、成员数)。"""
group_code = args.get("group_code", "")
if not group_code:
raise _YbError("group_code is required")
gi = await _adapter().query_group_info(group_code)
if gi is None:
raise _YbError("query_group_info returned None")
return {
"success": True, "group_code": group_code, "group_name": gi.get("group_name", ""),
"member_count": gi.get("member_count", 0),
"owner": {"user_id": gi.get("owner_id", ""), "nickname": gi.get("owner_nickname", "")},
"note": 'The group is called "派 (Pai)" in the app.',
}
@_yb_tool("query_group_members")
async def query_group_members(args) -> dict:
"""统一的群成员查询(对齐 TS query_session_members)。
action: find (按昵称模糊搜索; 无 name 时等同 list_all) / list_bots / list_all (默认)."""
group_code, name = args.get("group_code", ""), args.get("name", "")
action = args.get("action", "list_all")
if not group_code:
raise _YbError("group_code is required")
all_members = [
{"user_id": m.get("user_id", ""), "nickname": _nick(m),
"role": _USER_TYPE_LABEL.get(m.get("user_type", m.get("role", 0)), "unknown")}
for m in await _members(_adapter(), group_code)
]
if not all_members:
raise _YbError("No members found in this group.")
hint = {"mention_hint": MENTION_HINT} if args.get("mention", False) else {}
def _listing(ok: bool, msg: str, members: list) -> dict:
return {"success": ok, "msg": msg, "members": members, **hint}
if action == "list_bots":
bots = [m for m in all_members if m["role"] in {"yuanbao_ai", "bot"}]
if not bots:
raise _YbError("No bots found in this group.")
return _listing(True, f"Found {len(bots)} bot(s).", bots)
if action == "find" and name:
filt = name.strip().lower()
matched = [m for m in all_members if filt in m["nickname"].lower()]
if matched:
return _listing(True, f'Found {len(matched)} member(s) matching "{name}".', matched)
return _listing(False, f'No match for "{name}". All members listed below.', all_members)
return _listing(True, f"Found {len(all_members)} member(s).", all_members)
@_yb_tool("search_sticker")
async def search_sticker(args) -> dict:
"""在内置贴纸表中按关键词模糊搜索,返回 Top-N 候选(空 query 返回前 N 条)。"""
from gateway.platforms.yuanbao_sticker import search_stickers
query, limit = args.get("query", ""), args.get("limit", 10)
try:
safe_limit = max(1, min(50, int(limit) if limit else 10))
except (TypeError, ValueError):
safe_limit = 10
matches = search_stickers(query or "", limit=safe_limit)
return {
"success": True, "query": query or "", "count": len(matches),
"results": [{k: s.get(k, "") for k in ("sticker_id", "name", "description", "package_id")}
for s in matches],
}
@_yb_tool("send_sticker")
async def send_sticker(args) -> dict:
"""向 chat_id(缺省取当前会话 HERMES_SESSION_CHAT_ID)发送一张内置贴纸(TIMFaceElem)。
``sticker``: 名称(如 "六六六")或 sticker_id(如 "278");为空时随机发送。
``chat_id``: ``direct:{account_id}`` / ``group:{group_code}`` / 裸 account_id。"""
from gateway.platforms.yuanbao_sticker import get_sticker_by_id, get_sticker_by_name, get_random_sticker
target = (args.get("chat_id", "") or "").strip() or _session_env("HERMES_SESSION_CHAT_ID")
if not target:
raise _YbError("chat_id is required (no active yuanbao session detected)")
adapter = _adapter()
raw = (args.get("sticker", "") or "").strip()
if not raw:
sticker_obj = get_random_sticker()
else:
sticker_obj = (get_sticker_by_id(raw) if raw.isdigit() else None) or get_sticker_by_name(raw)
if sticker_obj is None:
raise _YbError(f"Sticker not found: {raw!r}. Use search_sticker first to discover available stickers.")
result = await adapter.send_sticker(chat_id=target, sticker_name=sticker_obj.get("name", ""),
reply_to=args.get("reply_to", "") or None)
if not getattr(result, "success", False):
raise _YbError(getattr(result, "error", "send_sticker failed"))
return {
"success": True, "chat_id": target,
"sticker": {"sticker_id": sticker_obj.get("sticker_id", ""), "name": sticker_obj.get("name", "")},
"message_id": getattr(result, "message_id", None),
"note": "Sticker delivered to the chat. If you have additional text to say, reply now; otherwise end your turn without generating text.",
}
@_yb_tool("send_dm")
async def send_dm(args) -> dict:
"""Send a DM to a group member, with optional media. group_code defaults to the session's
"group:<code>" chat_id. Without ``user_id`` the member list is searched by ``name`` (partial,
case-insensitive; >1 match returns candidates). media_files items are {"path", "is_voice"} dicts or
(path, is_voice) pairs; ``MEDIA:<path>`` tags in the text count too. Partial media failures are
reported in ``note``, not as failure."""
group_code = args.get("group_code", "")
if not group_code and (chat_id := _session_env("HERMES_SESSION_CHAT_ID")).startswith("group:"):
group_code = chat_id.split(":", 1)[1]
media_files = []
for item in args.get("media_files") or []:
if isinstance(item, dict):
media_files.append((item.get("path", ""), bool(item.get("is_voice", False))))
elif isinstance(item, (list, tuple)) and len(item) >= 2:
media_files.append((str(item[0]), bool(item[1])))
from gateway.platforms.base import BasePlatformAdapter
embedded_media, message = BasePlatformAdapter.extract_media(args.get("message", ""))
media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files + list(embedded_media or []))
if not message and not media_files:
raise _YbError("message or media_files is required")
adapter = _adapter()
name = args.get("name", "")
resolved_user_id, resolved_nickname = (args.get("user_id", "") or "").strip(), name.strip()
if not resolved_user_id:
resolved_user_id, resolved_nickname = await _resolve_dm_recipient(adapter, group_code, name)
if not resolved_user_id:
raise _YbError("Could not resolve user_id")
chat_id = f"direct:{resolved_user_id}"
last_result = None
errors: list[str] = []
if message and message.strip():
last_result = await adapter.send_dm(resolved_user_id, message, group_code=group_code)
if not last_result.success:
errors.append(last_result.error or "text send failed")
for media_path, _is_voice in media_files:
send = adapter.send_image_file if Path(media_path).suffix.lower() in _IMAGE_EXTS else adapter.send_document
last_result = await send(chat_id, media_path, group_code=group_code)
if not last_result.success:
errors.append(last_result.error or "media send failed")
if last_result is None:
raise _YbError("No deliverable text or media remained")
if errors and not last_result.success:
raise _YbError("; ".join(errors))
note = f'DM sent to "{resolved_nickname}" successfully.'
if errors:
note += f" (partial failure: {'; '.join(errors)})"
return {"success": True, "user_id": resolved_user_id, "nickname": resolved_nickname,
"message_id": last_result.message_id, "note": note}
def _check_yuanbao():
"""Toolset availability check — True when running in a yuanbao gateway session."""
return _session_env("HERMES_SESSION_PLATFORM") == "yuanbao" or _get_active_adapter() is not None
# (schema, handler, emoji); the tool name is schema["name"].
_TOOLS = (
(
{
"name": "yb_query_group_info",
"description": (
"Query basic info about a group (called '派/Pai' in the app), "
"including group name, owner, and member count."
),
"parameters": {
"type": "object",
"properties": {
"group_code": {
"type": "string",
"description": "The unique group identifier (group_code).",
},
},
"required": ["group_code"],
},
},
get_group_info,
"👥",
),
(
{
"name": "yb_query_group_members",
"description": (
"Query members of a group (called '派/Pai' in the app). "
"Use this tool when you need to @mention someone, find a user by name, "
"list bots (including Yuanbao AI), or list all members. "
"IMPORTANT: You MUST call this tool before @mentioning any user, "
"because you need the exact nickname to construct the @mention format."
),
"parameters": {
"type": "object",
"properties": {
"group_code": {
"type": "string",
"description": "The unique group identifier (group_code).",
},
"action": {
"type": "string",
"enum": ["find", "list_bots", "list_all"],
"description": (
"find — search a user by name (use when you need to @mention or look up someone); "
"list_bots — list bots and Yuanbao AI assistants; "
"list_all — list all members."
),
},
"name": {
"type": "string",
"description": (
"User name to search (partial match, case-insensitive). "
"Required for 'find'. Use the name the user mentioned in the conversation."
),
},
"mention": {
"type": "boolean",
"description": (
"Set to true when you need to @mention/at someone in your reply. "
"The response will include the exact @mention format to use."
),
},
},
"required": ["group_code", "action"],
},
},
query_group_members,
"📋",
),
(
{
"name": "yb_send_dm",
"description": (
"Send a private/direct message (DM) to a user in a group, with optional media files. "
"This tool automatically looks up the user by name in the group member list "
"and sends the message. Use this when someone asks to privately message / 私信 / DM a user. "
"Supports text, images, and file attachments. "
"You can also provide user_id directly if already known."
),
"parameters": {
"type": "object",
"properties": {
"group_code": {
"type": "string",
"description": (
"The group where the target user belongs. "
"Extract from chat_id: 'group:328306697' → '328306697'. "
"Required when user_id is not provided."
),
},
"name": {
"type": "string",
"description": (
"Target user's display name (partial match, case-insensitive). "
"Required when user_id is not provided."
),
},
"message": {
"type": "string",
"description": "The message text to send as a DM. Can be empty if only sending media.",
},
"user_id": {
"type": "string",
"description": (
"Target user's account ID. If provided, skips the member lookup. "
"Usually obtained from a previous yb_query_group_members call."
),
},
"media_files": {
"type": "array",
"description": (
"Optional list of media files to send along with the DM. "
"Images (.jpg/.png/.gif/.webp/.bmp) are sent as image messages; "
"other files are sent as document attachments."
),
"items": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Absolute local file path of the media to send.",
},
"is_voice": {
"type": "boolean",
"description": "Whether this file is a voice message (default false).",
},
},
"required": ["path"],
},
},
},
"required": [],
},
},
send_dm,
"✉️",
),
(
{
"name": "yb_search_sticker",
"description": (
"Search the built-in Yuanbao sticker (TIM face / 表情包) catalogue by keyword. "
"Returns the top matching candidates with sticker_id, name, and description. "
"Use this BEFORE yb_send_sticker to discover the right sticker_id. "
"Sticker = 贴纸 = TIM face — NOT a message reaction. "
"Prefer sending a sticker over bare Unicode emoji when reacting/expressing emotion."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": (
"Search keyword (Chinese or English, e.g. '666', '比心', 'cool', '吃瓜'). "
"Empty string returns the first N stickers."
),
},
"limit": {
"type": "integer",
"description": "Max number of candidates to return (default 10, max 50).",
},
},
"required": [],
},
},
search_sticker,
"🔍",
),
(
{
"name": "yb_send_sticker",
"description": (
"Send a built-in sticker (TIMFaceElem / 贴纸表情) to the current Yuanbao chat. "
"Call yb_search_sticker first if you don't know the sticker_id/name. "
"Sticker = 贴纸 = TIM face — NOT a message reaction. "
"CRITICAL: Whenever the user asks you to send a sticker / 贴纸 / 表情包, you MUST "
"use this tool. DO NOT draw a PNG via execute_code / Pillow / matplotlib and "
"then call send_image_file — that produces a fake 'sticker' image instead of a "
"real TIM face and is the WRONG path. If no suitable sticker_id is known, call "
"yb_search_sticker first. When the recent thread shows users sending stickers, "
"prefer matching that tone by replying with a sticker instead of (or in "
"addition to) text."
),
"parameters": {
"type": "object",
"properties": {
"sticker": {
"type": "string",
"description": (
"Sticker name (e.g. '六六六', '比心', 'ok') or numeric sticker_id "
"(e.g. '278'). Empty string sends a random built-in sticker."
),
},
"chat_id": {
"type": "string",
"description": (
"Target chat. Defaults to the current session. "
"Format: 'direct:{account_id}', 'group:{group_code}', or bare account_id."
),
},
"reply_to": {
"type": "string",
"description": "Optional ref_msg_id to quote-reply (group chat only).",
},
},
"required": [],
},
},
send_sticker,
"🎨",
),
)
for _schema, _handler, _emoji in _TOOLS:
registry.register(
name=_schema["name"], toolset="hermes-yuanbao", schema=_schema, handler=_handler,
check_fn=_check_yuanbao, is_async=True, emoji=_emoji,
)