diff options
| author | Hermes Agent <agent@nousresearch.com> | 2026-06-26 19:00:22 +0800 |
|---|---|---|
| committer | ntzyz <i@ntzyz.io> | 2026-06-26 19:00:22 +0800 |
| commit | de71e451c5f1283d642070ac5028fdbae842b988 (patch) | |
| tree | 8c1038ed1d4eb35b564a551d7f1a939523bb69d6 | |
feat: MiMo web search plugin for Hermes Agent
Xiaomi MiMo 联网搜索 (server-side web search) provider plugin.
- WebSearchProvider subclass calling MiMo Chat Completions API
with tools=[{type: web_search, force_search: true}]
- Parses url_citation annotations into Hermes standard format
- Supports XIAOMI_BASE_URL for Token Plan endpoints
- Standalone test_search.py CLI script (no Hermes dependency)
- Configurable model, max_keyword, timeout via config.yaml
| -rw-r--r-- | .gitignore | 8 | ||||
| -rw-r--r-- | README.md | 110 | ||||
| -rw-r--r-- | __init__.py | 14 | ||||
| -rw-r--r-- | plugin.yaml | 9 | ||||
| -rw-r--r-- | provider.py | 375 | ||||
| -rw-r--r-- | test_search.py | 221 |
6 files changed, 737 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d497e87 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +*.pyo +.env +.venv/ +*.egg-info/ +dist/ +build/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d26e2b2 --- /dev/null +++ b/README.md @@ -0,0 +1,110 @@ +# MiMo Web Search Plugin for Hermes Agent + +将小米 MiMo 的**联网搜索**(server-side web search)接入 Hermes Agent 的 `web_search` 工具。 + +MiMo 在服务端执行搜索、浏览网页、提取内容,通过 Chat Completions API 的 `url_citation` 注释返回结构化结果。适合中文搜索场景。 + +## 快速开始 + +### 1. 独立使用(无需 Hermes) + +```bash +# 安装依赖 +pip install httpx + +# 设置 API Key +export XIAOMI_API_KEY="your-api-key-here" + +# 如果使用 Token Plan(国内端点),还需设置 base URL +export XIAOMI_BASE_URL="https://token-plan-cn.xiaomimimo.com/v1" + +# 搜索 +python3 test_search.py "小米17解锁" +python3 test_search.py "YU7 GT 价格" --limit 10 +python3 test_search.py "今天天气" --raw # 输出完整 API 响应 +``` + +### 2. 接入 Hermes Agent + +将插件复制到 Hermes 的用户插件目录: + +```bash +cp -r ~/mimo-search-plugin ~/.hermes/plugins/web/mimo +``` + +在 `~/.hermes/config.yaml` 中启用: + +```yaml +plugins: + enabled: + - web-mimo + +web: + search_backend: "mimo" +``` + +环境变量(在 `~/.hermes/.env` 中设置): + +``` +XIAOMI_API_KEY=your-api-key-here +# 可选:Token Plan 国内端点 +XIAOMI_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1 +``` + +重启 Hermes 后,`web_search` 工具将使用 MiMo 作为搜索后端。 + +## 获取 API Key + +1. 访问 [platform.xiaomimimo.com](https://platform.xiaomimimo.com) +2. 注册/登录小米账号 +3. 创建 API Key +4. 在控制台 → 插件管理中**开通联网服务插件**(付费功能) + +## 可选配置 + +在 `~/.hermes/config.yaml` 的 `web.mimo` 下可调整: + +```yaml +web: + search_backend: "mimo" + mimo: + model: "mimo-v2.5" # 可选: mimo-v2.5-pro, mimo-v2.5, mimo-v2-flash 等 + max_keyword: 3 # 每轮搜索最大关键词数(影响调用次数和费用) + timeout: 30 # 请求超时秒数 +``` + +## 计费 + +- **联网搜索工具**:国内 ¥16/1000 次,海外 $5/1000 次 +- **Token 消耗**:搜索结果拼入 prompt,按模型输入 token 标准价计费 +- 每轮搜索可能触发 `max_keyword` 个关键词,每个关键词算一次调用 + +## 工作原理 + +``` +Hermes web_search 工具 + ↓ +MiMoWebSearchProvider.search(query, limit) + ↓ +POST {base_url}/chat/completions + tools: [{type: "web_search", force_search: true, ...}] + ↓ +MiMo 服务端搜索 → 返回 annotations: [{type: "url_citation", ...}] + ↓ +解析为 Hermes 标准格式: {title, url, description, position} +``` + +## 文件说明 + +| 文件 | 说明 | +|---|---| +| `provider.py` | `WebSearchProvider` 子类,核心搜索逻辑 | +| `__init__.py` | 插件注册入口 | +| `plugin.yaml` | Hermes 插件清单 | +| `test_search.py` | 独立测试脚本(可脱离 Hermes 使用) | + +## API 参考 + +- [MiMo 联网搜索文档](https://mimo.mi.com/docs/zh-CN/guides/text-generation/web-search) +- [MiMo 平台](https://platform.xiaomimimo.com) +- [Hermes Web Search Provider 插件开发指南](https://hermes-agent.nousresearch.com/docs/developer-guide/web-search-provider-plugin) diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..c1cd62d --- /dev/null +++ b/__init__.py @@ -0,0 +1,14 @@ +"""Xiaomi MiMo web search plugin. + +Mirrors the ``plugins/web/brave_free/`` layout: ``provider.py`` holds the +provider class, ``__init__.py::register(ctx)`` registers an instance. +""" + +from __future__ import annotations + +from plugins.web.mimo.provider import MiMoWebSearchProvider + + +def register(ctx) -> None: + """Register the MiMo Web Search provider with the plugin context.""" + ctx.register_web_search_provider(MiMoWebSearchProvider()) diff --git a/plugin.yaml b/plugin.yaml new file mode 100644 index 0000000..a7e620d --- /dev/null +++ b/plugin.yaml @@ -0,0 +1,9 @@ +name: web-mimo +version: 1.0.0 +description: "Xiaomi MiMo Web Search — server-side web search via MiMo's 联网搜索 tool (Chat Completions API). Requires XIAOMI_API_KEY (https://platform.xiaomimimo.com)." +author: NousResearch +kind: backend +provides_web_providers: + - mimo +requires_env: + - XIAOMI_API_KEY diff --git a/provider.py b/provider.py new file mode 100644 index 0000000..f7bbb8d --- /dev/null +++ b/provider.py @@ -0,0 +1,375 @@ +"""Xiaomi MiMo Web Search — plugin form. + +Subclasses :class:`agent.web_search_provider.WebSearchProvider`. Routes +``web_search`` tool calls through MiMo's server-side 联网搜索 (web search) +tool on the Chat Completions API. MiMo performs the actual searching and +page-browsing server-side; we ask it to return the top results as structured +JSON so we can hand back the same ``{title, url, description, position}`` +rows every other Hermes web provider produces. + +Reference: https://mimo.mi.com/docs/zh-CN/guides/text-generation/web-search + +Config keys this provider responds to:: + + web: + search_backend: "mimo" # explicit per-capability + backend: "mimo" # shared fallback + +Optional knobs (under ``web.mimo`` in ``config.yaml``):: + + web: + mimo: + model: "mimo-v2.5" # default model for search queries + max_keyword: 3 # max keywords per search round + timeout: 30 # seconds (default 30) + +Auth env var:: + + XIAOMI_API_KEY=... # https://platform.xiaomimimo.com + XIAOMI_BASE_URL=... # optional, defaults to https://api.xiaomimimo.com/v1 +""" + +from __future__ import annotations + +import json +import logging +import os +import re +from typing import Any, Dict, List, Optional + +from agent.web_search_provider import WebSearchProvider + +logger = logging.getLogger(__name__) + +DEFAULT_MODEL = "mimo-v2.5" +DEFAULT_TIMEOUT = 30 +DEFAULT_MAX_KEYWORD = 3 +_DEFAULT_BASE_URL = "https://api.xiaomimimo.com/v1" + +# Match JSON block in model output (tolerates leading/trailing prose). +_JSON_BLOCK_RE = re.compile(r"\{[\s\S]*\}", re.MULTILINE) + + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + + +def _load_mimo_web_config() -> Dict[str, Any]: + """Read ``web.mimo`` from config.yaml (returns {} on miss).""" + try: + from hermes_cli.config import load_config + + cfg = load_config() + web_section = cfg.get("web") if isinstance(cfg, dict) else None + mimo_section = web_section.get("mimo") if isinstance(web_section, dict) else None + return mimo_section if isinstance(mimo_section, dict) else {} + except Exception as exc: # noqa: BLE001 + logger.debug("Could not load web.mimo config: %s", exc) + return {} + + +# --------------------------------------------------------------------------- +# Provider +# --------------------------------------------------------------------------- + + +class MiMoWebSearchProvider(WebSearchProvider): + """Search-only provider backed by Xiaomi MiMo's 联网搜索 tool. + + Sends a structured prompt to MiMo with ``tools=[{"type": "web_search"}]`` + enabled and asks it to return the top *limit* results as JSON. Falls + back to the response ``annotations`` (url_citation entries) if MiMo + ignores the JSON schema instruction. + + No extract capability — pair with Firecrawl / Tavily / Exa for + ``web_extract`` if you need page content. + """ + + @property + def name(self) -> str: + return "mimo" + + @property + def display_name(self) -> str: + return "Xiaomi MiMo Web Search" + + def is_available(self) -> bool: + """Cheap availability probe — env var present.""" + return bool(os.getenv("XIAOMI_API_KEY", "").strip()) + + def supports_search(self) -> bool: + return True + + def supports_extract(self) -> bool: + return False + + # -- Search ----------------------------------------------------------- + + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + """Execute a MiMo-backed web search. + + Returns ``{"success": True, "data": {"web": [{title, url, description, position}, ...]}}`` + on success, ``{"success": False, "error": str}`` on failure. + """ + try: + from tools.interrupt import is_interrupted + + if is_interrupted(): + return {"success": False, "error": "Interrupted"} + except Exception: # noqa: BLE001 — interrupt module is best-effort + pass + + api_key = os.getenv("XIAOMI_API_KEY", "").strip() + if not api_key: + return { + "success": False, + "error": "XIAOMI_API_KEY is not set. Get one at https://platform.xiaomimimo.com", + } + + try: + limit = int(limit) + except (TypeError, ValueError): + limit = 5 + limit = max(1, min(limit, 20)) + + cfg = _load_mimo_web_config() + model = cfg.get("model") if isinstance(cfg.get("model"), str) else DEFAULT_MODEL + model = model.strip() or DEFAULT_MODEL + + try: + timeout = float(cfg.get("timeout", DEFAULT_TIMEOUT)) + except (TypeError, ValueError): + timeout = DEFAULT_TIMEOUT + + base_url = os.getenv("XIAOMI_BASE_URL", "").strip().rstrip("/") or _DEFAULT_BASE_URL + + try: + max_keyword = int(cfg.get("max_keyword", DEFAULT_MAX_KEYWORD)) + except (TypeError, ValueError): + max_keyword = DEFAULT_MAX_KEYWORD + max_keyword = max(1, min(max_keyword, 10)) + + prompt = self._build_prompt(query, limit) + + payload: Dict[str, Any] = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "tools": [ + { + "type": "web_search", + "force_search": True, + "max_keyword": max_keyword, + "limit": max(1, min(limit, 5)), + } + ], + "max_completion_tokens": 1024, + "temperature": 1.0, + "top_p": 0.95, + "stream": False, + } + + headers = { + "api-key": api_key, + "Content-Type": "application/json", + } + + try: + import httpx + except ImportError: + return { + "success": False, + "error": "httpx is not installed (required for MiMo web search)", + } + + logger.info( + "MiMo web search via %s: '%s' (limit=%d, model=%s, max_keyword=%d)", + base_url, query, limit, model, max_keyword, + ) + + try: + resp = httpx.post( + f"{base_url}/chat/completions", + headers=headers, + json=payload, + timeout=timeout, + ) + resp.raise_for_status() + except httpx.HTTPStatusError as exc: + status = exc.response.status_code if exc.response is not None else 0 + body = "" + try: + body = exc.response.text[:500] if exc.response is not None else "" + except Exception: + body = "" + logger.warning("MiMo web search HTTP %d: %s", status, body) + return { + "success": False, + "error": f"MiMo web search returned HTTP {status}: {body}".rstrip(), + } + except httpx.RequestError as exc: + logger.warning("MiMo web search request error: %s", exc) + return {"success": False, "error": f"Could not reach MiMo API: {exc}"} + + try: + data = resp.json() + except Exception as exc: # noqa: BLE001 + logger.warning("MiMo web search bad JSON: %s", exc) + return { + "success": False, + "error": "Could not parse MiMo API response as JSON", + } + + # Check for API-level error envelope. + api_error = data.get("error") if isinstance(data, dict) else None + if isinstance(api_error, dict): + err_msg = api_error.get("message") or api_error.get("code") or "unknown error" + logger.warning("MiMo web search returned error envelope: %s", err_msg) + return {"success": False, "error": f"MiMo returned an error: {err_msg}"} + + web_results = self._extract_results(data, limit=limit) + if not web_results: + return {"success": True, "data": {"web": []}} + + return {"success": True, "data": {"web": web_results}} + + # -- Prompt + parsing ------------------------------------------------- + + @staticmethod + def _build_prompt(query: str, limit: int) -> str: + """Compose the prompt that asks MiMo to act as a search engine.""" + return ( + "Use the web_search tool to find current information for the query below, " + "then respond with ONLY a single JSON object — no prose, no markdown " + "fences — matching this exact schema:\n\n" + '{"results": [{"title": "string", "url": "string", ' + '"description": "1-2 sentence summary"}]}\n\n' + f"Return at most {limit} results, ordered by relevance, with absolute " + "https:// URLs. If no usable results exist, return " + '{"results": []}.\n\n' + f"Query: {query}" + ) + + @classmethod + def _extract_results( + cls, + response_data: Dict[str, Any], + *, + limit: int, + ) -> List[Dict[str, Any]]: + """Pull a ``[{title, url, description, position}, ...]`` list out of + a Chat Completions API reply. + + Strategy: + 1. Walk ``choices[*].message.content`` for JSON blocks and try to + parse the first object that has a ``results`` list. + 2. If the JSON path fails, fall back to ``choices[*].message.annotations`` + (``url_citation`` entries). + """ + choices = response_data.get("choices") + if not isinstance(choices, list) or not choices: + return [] + + message = choices[0].get("message") if isinstance(choices[0], dict) else {} + if not isinstance(message, dict): + return {} + + content = message.get("content") or "" + annotations = message.get("annotations") or [] + + # Primary path: parse JSON from model output. + if isinstance(content, str) and content.strip(): + parsed = cls._try_parse_json_results(content, limit=limit) + if parsed: + return parsed + + # Secondary path: derive results from annotations (url_citation). + if isinstance(annotations, list): + annotation_results = cls._results_from_annotations(annotations, limit=limit) + if annotation_results: + return annotation_results + + return [] + + @staticmethod + def _try_parse_json_results( + text: str, + *, + limit: int, + ) -> Optional[List[Dict[str, Any]]]: + """Parse a JSON object with a ``results`` array out of ``text``.""" + candidates = [text] + match = _JSON_BLOCK_RE.search(text) + if match and match.group(0) != text: + candidates.append(match.group(0)) + + for candidate in candidates: + try: + parsed = json.loads(candidate) + except (json.JSONDecodeError, ValueError): + continue + if not isinstance(parsed, dict): + continue + results = parsed.get("results") + if not isinstance(results, list): + continue + normalized: List[Dict[str, Any]] = [] + for row in results[:limit]: + if not isinstance(row, dict): + continue + url = str(row.get("url", "")).strip() + if not url: + continue + normalized.append( + { + "title": str(row.get("title", "")).strip(), + "url": url, + "description": str(row.get("description", "")).strip(), + "position": len(normalized) + 1, + } + ) + if normalized: + return normalized + return None + + @staticmethod + def _results_from_annotations( + annotations: List[Dict[str, Any]], + *, + limit: int, + ) -> List[Dict[str, Any]]: + """Extract search results from MiMo's url_citation annotations.""" + results: List[Dict[str, Any]] = [] + for ann in annotations: + if not isinstance(ann, dict): + continue + if ann.get("type") != "url_citation": + continue + url = str(ann.get("url", "")).strip() + if not url: + continue + results.append( + { + "title": str(ann.get("title", "")).strip(), + "url": url, + "description": str(ann.get("summary", "")).strip(), + "position": len(results) + 1, + } + ) + if len(results) >= limit: + break + return results + + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "Xiaomi MiMo Web Search", + "badge": "paid", + "tag": "MiMo 联网搜索 — server-side search, great for Chinese content.", + "env_vars": [ + { + "key": "XIAOMI_API_KEY", + "prompt": "Xiaomi MiMo API key", + "url": "https://platform.xiaomimimo.com", + }, + ], + } diff --git a/test_search.py b/test_search.py new file mode 100644 index 0000000..c103bdd --- /dev/null +++ b/test_search.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""Standalone test script for MiMo Web Search. + +Usage: + export XIAOMI_API_KEY="your-key-here" + # export XIAOMI_BASE_URL="https://token-plan-cn.xiaomimimo.com/v1" # if using Token Plan + python3 test_search.py "小米17解锁" + python3 test_search.py "YU7 GT 价格" --limit 10 + python3 test_search.py "今天天气" --raw # dump full API response +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys + +# --------------------------------------------------------------------------- +# Env loader +# --------------------------------------------------------------------------- + +def _load_env() -> None: + """Load ~/.hermes/.env if present (no-op when env vars are already set).""" + env_path = os.path.expanduser("~/.hermes/.env") + if not os.path.exists(env_path): + return + with open(env_path) as f: + for line in f: + line = line.strip() + if line and not line.startswith("#") and "=" in line: + k, v = line.split("=", 1) + k, v = k.strip(), v.strip() + if k and k not in os.environ: + os.environ[k] = v + +# --------------------------------------------------------------------------- +# Core search (self-contained, no Hermes imports needed) +# --------------------------------------------------------------------------- + +import httpx, re + +_DEFAULT_BASE_URL = "https://api.xiaomimimo.com/v1" +_DEFAULT_MODEL = "mimo-v2.5" +_JSON_BLOCK_RE = re.compile(r"\{[\s\S]*\}", re.MULTILINE) + + +def mimo_search( + query: str, + *, + limit: int = 5, + model: str | None = None, + max_keyword: int = 3, + raw: bool = False, +) -> dict: + """Call MiMo's Chat Completions API with web_search tool enabled.""" + api_key = os.environ.get("XIAOMI_API_KEY", "").strip() + if not api_key: + return {"success": False, "error": "XIAOMI_API_KEY not set"} + + base_url = os.environ.get("XIAOMI_BASE_URL", "").strip().rstrip("/") or _DEFAULT_BASE_URL + model = model or _DEFAULT_MODEL + + prompt = ( + "Use the web_search tool to find current information for the query below, " + "then respond with ONLY a single JSON object — no prose, no markdown " + "fences — matching this exact schema:\n\n" + '{"results": [{"title": "string", "url": "string", ' + '"description": "1-2 sentence summary"}]}\n\n' + f"Return at most {limit} results, ordered by relevance, with absolute " + 'https:// URLs. If no usable results exist, return ' + '{"results": []}.\n\n' + f"Query: {query}" + ) + + payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "tools": [ + { + "type": "web_search", + "force_search": True, + "max_keyword": max_keyword, + "limit": min(limit, 5), + } + ], + "max_completion_tokens": 2048, + "temperature": 1.0, + "top_p": 0.95, + "stream": False, + } + + resp = httpx.post( + f"{base_url}/chat/completions", + headers={"api-key": api_key, "Content-Type": "application/json"}, + json=payload, + timeout=30, + ) + resp.raise_for_status() + data = resp.json() + + if raw: + return data + + # Parse results from annotations + choices = data.get("choices", []) + if not choices: + return {"success": True, "data": {"web": []}} + + message = choices[0].get("message", {}) + annotations = message.get("annotations", []) + + seen_urls: set[str] = set() + results = [] + for ann in annotations: + if ann.get("type") != "url_citation": + continue + url = str(ann.get("url", "")).strip() + if not url or url in seen_urls: + continue + seen_urls.add(url) + results.append({ + "title": str(ann.get("title", "")).strip(), + "url": url, + "description": str(ann.get("summary", "")).strip(), + "site_name": str(ann.get("site_name", "")).strip(), + "publish_time": str(ann.get("publish_time", "")).strip(), + "position": len(results) + 1, + }) + if len(results) >= limit: + break + + usage = data.get("usage", {}) + wsu = usage.get("web_search_usage", {}) + + return { + "success": True, + "data": {"web": results}, + "answer": message.get("content", ""), + "usage": { + "prompt_tokens": usage.get("prompt_tokens"), + "completion_tokens": usage.get("completion_tokens"), + "total_tokens": usage.get("total_tokens"), + "search_keywords": wsu.get("tool_usage"), + "pages_fetched": wsu.get("page_usage"), + }, + } + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser(description="MiMo Web Search CLI") + parser.add_argument("query", help="Search query") + parser.add_argument("--limit", type=int, default=5, help="Max results (default: 5)") + parser.add_argument("--model", default=None, help="MiMo model (default: mimo-v2.5)") + parser.add_argument("--max-keyword", type=int, default=3, help="Max keywords per round") + parser.add_argument("--raw", action="store_true", help="Dump raw API response") + args = parser.parse_args() + + _load_env() + + try: + result = mimo_search( + args.query, + limit=args.limit, + model=args.model, + max_keyword=args.max_keyword, + raw=args.raw, + ) + except httpx.HTTPStatusError as exc: + print(f"HTTP {exc.response.status_code}: {exc.response.text[:500]}", file=sys.stderr) + sys.exit(1) + except httpx.RequestError as exc: + print(f"Connection error: {exc}", file=sys.stderr) + sys.exit(1) + + if args.raw: + print(json.dumps(result, ensure_ascii=False, indent=2)) + return + + if not result.get("success"): + print(f"Error: {result.get('error')}", file=sys.stderr) + sys.exit(1) + + answer = result.get("answer", "") + web = result.get("data", {}).get("web", []) + usage = result.get("usage", {}) + + if answer: + print(f"\n{'=' * 60}") + print("ANSWER") + print('=' * 60) + print(answer) + + print(f"\n{'=' * 60}") + print(f"SEARCH RESULTS ({len(web)} items)") + print('=' * 60) + for r in web: + site = f" [{r.get('site_name', '')}]" if r.get("site_name") else "" + print(f"\n [{r['position']}]{site} {r['title']}") + print(f" {r['url']}") + desc = r.get("description", "") + if desc: + print(f" {desc[:150]}{'...' if len(desc) > 150 else ''}") + + print(f"\n{'=' * 60}") + print("USAGE") + print('=' * 60) + print(f" prompt tokens: {usage.get('prompt_tokens', '?')}") + print(f" completion tokens: {usage.get('completion_tokens', '?')}") + print(f" total tokens: {usage.get('total_tokens', '?')}") + print(f" search keywords: {usage.get('search_keywords', '?')}") + print(f" pages fetched: {usage.get('pages_fetched', '?')}") + print() + + +if __name__ == "__main__": + main() |
