From fefd9c0c1b871828a179f6d9e17c10960912532d Mon Sep 17 00:00:00 2001 From: Julien Talbot Date: Sun, 10 May 2026 21:56:54 +0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(xai):=20add=20xai=5Fweb=5Fsearch=20too?= =?UTF-8?q?l=20=E2=80=94=20live=20web=20search=20via=20xAI=20Responses=20A?= =?UTF-8?q?PI=20web=5Fsearch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xAI's Responses API supports a web_search built-in tool: pass tools: [{type: "web_search", ...}] and the model searches the live web, scrapes pages it finds, and grounds its answer with citations. Per the xAI API reference: "only functions and web search are supported as tools" — so this is the canonical way to wire xAI-native web search into Hermes without going through a third-party search provider. This is the web counterpart of x_search (#14541) which targets X / Twitter; xai_web_search targets the open web. Changes: - tools/xai_web_search_tool.py: self-contained tool implementation - web_search_tool(query, allowed_websites, excluded_websites, from_date, to_date, country) → JSON-encoded result with {success, provider, tool, model, query, answer, citations, inline_citations} - Uses Responses API tool_def shape symmetric with x_search: type=web_search, optional allowed_websites/excluded_websites (max 10, mutually exclusive), from_date/to_date (ISO YYYY-MM-DD), country (uppercased ISO alpha-2) - Citation extraction: top-level data.citations + inline url_citation annotations on output[*].content[*].annotations - Retry on 5xx and read-timeout/connection errors with exponential backoff capped at 5s; no retry on 4xx (auth) - Configurable via config.yaml under web_search: {model, timeout_seconds, retries} - Reuses tools.xai_http.hermes_xai_user_agent - Self-registers via tools.registry.registry.register - Tool name xai_web_search (the bare web_search name is already taken by tools.web_tools) - tests/tools/test_xai_web_search_tool.py: 33 unit tests - check_web_search_requirements (with/without/blank API key) - schema (required query, optional params advertised) - _normalize_websites (strips protocol/www/path, drops empty, rejects >10, handles None) - argument validation (empty query, missing API key, allowed + excluded mutually exclusive) - body construction (default model, /responses endpoint, allowed websites, excluded websites, date range, country uppercase, minimal tool_def, headers) - response parsing (answer, top-level citations, inline citations, legacy output_text fallback, multi-piece concat, non-url types skipped) - HTTP errors (401 surface, 500 retry then succeed, 500 exhaust retries, 4xx no retry) - toolsets.py: add xai_web_search to _HERMES_CORE_TOOLS and new xai_web_search toolset - hermes_cli/tools_config.py: add xai_web_search to CONFIGURABLE_TOOLSETS - tests/tools/test_registry.py: add tools.xai_web_search_tool to manual builtin tool set snapshot Requires XAI_API_KEY in ~/.hermes/.env. --- hermes_cli/tools_config.py | 1 + tests/tools/test_registry.py | 1 + tests/tools/test_xai_web_search_tool.py | 332 ++++++++++++++++++++ tools/xai_web_search_tool.py | 396 ++++++++++++++++++++++++ toolsets.py | 13 + 5 files changed, 743 insertions(+) create mode 100644 tests/tools/test_xai_web_search_tool.py create mode 100644 tools/xai_web_search_tool.py diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 74fc29247d264..bc8dfe02a6b0b 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -76,6 +76,7 @@ ("discord_admin", "🛡️ Discord Server Admin", "list channels/roles, pin, assign roles"), ("yuanbao", "🤖 Yuanbao", "group info, member queries, DM"), ("computer_use", "🖱️ Computer Use (macOS)", "background desktop control via cua-driver"), + ("xai_web_search", "🌐 xAI Web Search", "live web search via xAI Responses API web_search"), ] # Toolsets that are OFF by default for new installs. diff --git a/tests/tools/test_registry.py b/tests/tools/test_registry.py index 0023b5c9bd2c6..989bd13ba1dcb 100644 --- a/tests/tools/test_registry.py +++ b/tests/tools/test_registry.py @@ -319,6 +319,7 @@ def test_matches_previous_manual_builtin_tool_set(self): "tools.tts_tool", "tools.vision_tools", "tools.web_tools", + "tools.xai_web_search_tool", "tools.yuanbao_tools", } diff --git a/tests/tools/test_xai_web_search_tool.py b/tests/tools/test_xai_web_search_tool.py new file mode 100644 index 0000000000000..095d99937e8a4 --- /dev/null +++ b/tests/tools/test_xai_web_search_tool.py @@ -0,0 +1,332 @@ +"""Unit tests for tools.xai_web_search_tool.""" +from __future__ import annotations + +import json +from typing import Any, Dict, List + +import pytest +import requests + +from tools import xai_web_search_tool +from tools.xai_web_search_tool import ( + WEB_SEARCH_SCHEMA, + _extract_inline_citations, + _extract_response_text, + _normalize_websites, + check_web_search_requirements, + web_search_tool, +) + + +# --------------------------------------------------------------------------- +# Fake requests.post +# --------------------------------------------------------------------------- + +class _FakeResponse: + def __init__(self, status_code: int, payload: Any = None, text: str = ""): + self.status_code = status_code + self._payload = payload + self.text = text or (json.dumps(payload) if payload is not None else "") + + def json(self) -> Any: + if self._payload is None: + raise ValueError("no JSON") + return self._payload + + def raise_for_status(self) -> None: + if self.status_code >= 400: + err = requests.HTTPError(f"HTTP {self.status_code}") + err.response = self # type: ignore[attr-defined] + raise err + + +class _FakePost: + def __init__(self, *responses: _FakeResponse): + self.queue = list(responses) + self.calls: List[Dict[str, Any]] = [] + + def __call__(self, url: str, *, headers, json=None, timeout=None): + self.calls.append({"url": url, "headers": headers, "json": json, "timeout": timeout}) + if not self.queue: + raise AssertionError("ran out of mocked responses") + return self.queue.pop(0) + + +@pytest.fixture +def fake_post(monkeypatch): + holder: Dict[str, _FakePost] = {} + + def install(*responses: _FakeResponse) -> _FakePost: + fp = _FakePost(*responses) + holder["fp"] = fp + monkeypatch.setattr(xai_web_search_tool.requests, "post", fp) + monkeypatch.setattr(xai_web_search_tool.time, "sleep", lambda _s: None) + return fp + + return install + + +@pytest.fixture(autouse=True) +def _no_disk_config(monkeypatch): + monkeypatch.setattr(xai_web_search_tool, "_load_web_search_config", lambda: {}) + + +@pytest.fixture +def api_key(monkeypatch): + monkeypatch.setenv("XAI_API_KEY", "sk-test-web") + + +def _ok(answer: str = "Some answer", citations=None, inline=None) -> _FakeResponse: + payload: Dict[str, Any] = { + "id": "resp-1", + "object": "response", + "status": "completed", + "output": [ + {"type": "message", "role": "assistant", + "content": [ + {"type": "output_text", "text": answer, "annotations": list(inline or [])}, + ]}, + ], + "citations": list(citations or []), + } + return _FakeResponse(200, payload) + + +# --------------------------------------------------------------------------- +# Requirements / schema +# --------------------------------------------------------------------------- + +class TestRequirements: + def test_unavailable_without_key(self, monkeypatch): + monkeypatch.delenv("XAI_API_KEY", raising=False) + assert check_web_search_requirements() is False + + def test_blank_key_unavailable(self, monkeypatch): + monkeypatch.setenv("XAI_API_KEY", " ") + assert check_web_search_requirements() is False + + def test_available_with_key(self, monkeypatch): + monkeypatch.setenv("XAI_API_KEY", "sk") + assert check_web_search_requirements() is True + + +class TestSchema: + def test_required_query(self): + assert WEB_SEARCH_SCHEMA["parameters"]["required"] == ["query"] + + def test_advertised_optional_params(self): + props = WEB_SEARCH_SCHEMA["parameters"]["properties"] + for key in ("query", "allowed_websites", "excluded_websites", + "from_date", "to_date", "country"): + assert key in props + + +# --------------------------------------------------------------------------- +# _normalize_websites +# --------------------------------------------------------------------------- + +class TestNormalizeWebsites: + def test_strips_protocol(self): + assert _normalize_websites(["https://nytimes.com"], "x") == ["nytimes.com"] + assert _normalize_websites(["http://example.com"], "x") == ["example.com"] + + def test_strips_www_prefix(self): + assert _normalize_websites(["www.nytimes.com"], "x") == ["nytimes.com"] + + def test_strips_path(self): + assert _normalize_websites(["nytimes.com/section/foo"], "x") == ["nytimes.com"] + + def test_strips_protocol_and_path(self): + assert _normalize_websites(["https://www.nytimes.com/foo/bar"], "x") == ["nytimes.com"] + + def test_drops_empty(self): + assert _normalize_websites(["", " "], "x") == [] + + def test_too_many_raises(self): + with pytest.raises(ValueError): + _normalize_websites([f"site{i}.com" for i in range(11)], "allowed_websites") + + def test_none_yields_empty(self): + assert _normalize_websites(None, "x") == [] + + +# --------------------------------------------------------------------------- +# Argument validation +# --------------------------------------------------------------------------- + +class TestArgValidation: + def test_empty_query_returns_error(self, api_key): + out = json.loads(web_search_tool("")) + assert out["success"] is False + assert "query" in out["error"].lower() + + def test_missing_api_key_returns_error(self, monkeypatch): + monkeypatch.delenv("XAI_API_KEY", raising=False) + out = json.loads(web_search_tool("hello")) + assert out["success"] is False + assert "XAI_API_KEY" in out["error"] + + def test_allowed_and_excluded_are_mutually_exclusive(self, api_key): + out = json.loads(web_search_tool( + "hello", + allowed_websites=["nytimes.com"], + excluded_websites=["bbc.co.uk"], + )) + assert out["success"] is False + assert "cannot be used together" in out["error"] + + +# --------------------------------------------------------------------------- +# Body construction +# --------------------------------------------------------------------------- + +class TestBodyConstruction: + def test_basic_query_uses_default_model(self, api_key, fake_post): + fp = fake_post(_ok()) + web_search_tool("latest AI news") + body = fp.calls[0]["json"] + assert body["model"] == "grok-4.3" + assert body["input"][0]["content"] == "latest AI news" + assert body["tools"] == [{"type": "web_search"}] + assert body["store"] is False + + def test_endpoint_is_responses(self, api_key, fake_post): + fp = fake_post(_ok()) + web_search_tool("hello") + assert fp.calls[0]["url"].endswith("/responses") + + def test_allowed_websites_threaded_into_tool(self, api_key, fake_post): + fp = fake_post(_ok()) + web_search_tool("hi", allowed_websites=["https://www.nytimes.com", "bbc.co.uk"]) + tool = fp.calls[0]["json"]["tools"][0] + assert tool["allowed_websites"] == ["nytimes.com", "bbc.co.uk"] + + def test_excluded_websites_threaded_into_tool(self, api_key, fake_post): + fp = fake_post(_ok()) + web_search_tool("hi", excluded_websites=["pinterest.com"]) + tool = fp.calls[0]["json"]["tools"][0] + assert tool["excluded_websites"] == ["pinterest.com"] + + def test_date_range_threaded(self, api_key, fake_post): + fp = fake_post(_ok()) + web_search_tool("hi", from_date="2026-01-01", to_date="2026-05-01") + tool = fp.calls[0]["json"]["tools"][0] + assert tool["from_date"] == "2026-01-01" + assert tool["to_date"] == "2026-05-01" + + def test_country_uppercased(self, api_key, fake_post): + fp = fake_post(_ok()) + web_search_tool("hi", country="fr") + tool = fp.calls[0]["json"]["tools"][0] + assert tool["country"] == "FR" + + def test_minimal_tool_def_when_no_options(self, api_key, fake_post): + fp = fake_post(_ok()) + web_search_tool("hi") + tool = fp.calls[0]["json"]["tools"][0] + assert tool == {"type": "web_search"} + + def test_headers(self, api_key, fake_post): + fp = fake_post(_ok()) + web_search_tool("hi") + h = fp.calls[0]["headers"] + assert h["Authorization"] == "Bearer sk-test-web" + assert h["Content-Type"] == "application/json" + assert h["User-Agent"].startswith("Hermes-Agent/") + + +# --------------------------------------------------------------------------- +# Response parsing +# --------------------------------------------------------------------------- + +class TestResponseParsing: + def test_returns_answer(self, api_key, fake_post): + fake_post(_ok(answer="The answer is 42.")) + out = json.loads(web_search_tool("question")) + assert out["success"] is True + assert out["answer"] == "The answer is 42." + + def test_returns_top_level_citations(self, api_key, fake_post): + cites = [{"url": "https://nytimes.com/x", "title": "X"}] + fake_post(_ok(citations=cites)) + out = json.loads(web_search_tool("q")) + assert out["citations"] == cites + + def test_returns_inline_citations(self, api_key, fake_post): + inline = [ + {"type": "url_citation", "url": "https://a.com", "title": "A", + "start_index": 0, "end_index": 5}, + ] + fake_post(_ok(inline=inline)) + out = json.loads(web_search_tool("q")) + assert out["inline_citations"] == [ + {"url": "https://a.com", "title": "A", "start_index": 0, "end_index": 5}, + ] + + def test_extract_text_uses_output_text_legacy(self): + assert _extract_response_text({"output_text": "legacy"}) == "legacy" + + def test_extract_text_walks_message_output(self): + payload = { + "output": [ + {"type": "message", + "content": [ + {"type": "output_text", "text": "Part1"}, + {"type": "output_text", "text": "Part2"}, + ]}, + ], + } + assert _extract_response_text(payload) == "Part1\n\nPart2" + + def test_extract_inline_citations_skips_non_url_types(self): + payload = { + "output": [ + {"type": "message", + "content": [ + {"type": "output_text", "text": "x", "annotations": [ + {"type": "url_citation", "url": "https://a"}, + {"type": "footnote", "url": "https://b"}, + ]}, + ]}, + ], + } + out = _extract_inline_citations(payload) + assert len(out) == 1 + assert out[0]["url"] == "https://a" + + +# --------------------------------------------------------------------------- +# HTTP errors +# --------------------------------------------------------------------------- + +class TestHttpErrors: + def test_401_returns_error_json(self, api_key, fake_post): + fake_post(_FakeResponse(401, text='{"error":"bad key"}')) + out = json.loads(web_search_tool("hi")) + assert out["success"] is False + assert "401" in out["error"] + assert out["error_type"] == "HTTPError" + + def test_500_retries_then_succeeds(self, api_key, monkeypatch, fake_post): + # First a 500, then a 200. + fp = fake_post(_FakeResponse(500, text="oops"), _ok(answer="OK")) + # Bump retries so the loop tries twice. + monkeypatch.setattr(xai_web_search_tool, "_get_web_search_retries", lambda: 1) + out = json.loads(web_search_tool("hi")) + assert out["success"] is True + assert out["answer"] == "OK" + assert len(fp.calls) == 2 + + def test_500_exhausts_retries(self, api_key, monkeypatch, fake_post): + fake_post(_FakeResponse(500, text="boom"), _FakeResponse(500, text="boom")) + monkeypatch.setattr(xai_web_search_tool, "_get_web_search_retries", lambda: 1) + out = json.loads(web_search_tool("hi")) + assert out["success"] is False + assert "500" in out["error"] + + def test_4xx_does_not_retry(self, api_key, monkeypatch, fake_post): + fp = fake_post(_FakeResponse(403, text="forbidden")) + monkeypatch.setattr(xai_web_search_tool, "_get_web_search_retries", lambda: 5) + out = json.loads(web_search_tool("hi")) + assert out["success"] is False + assert len(fp.calls) == 1 # no retry on 4xx diff --git a/tools/xai_web_search_tool.py b/tools/xai_web_search_tool.py new file mode 100644 index 0000000000000..ff022f660b88a --- /dev/null +++ b/tools/xai_web_search_tool.py @@ -0,0 +1,396 @@ +"""xAI web_search tool — search the live web via xAI's Responses API +``web_search`` built-in tool, returns answer + citations. + +This is the web counterpart of ``x_search`` (which targets X / Twitter): +``web_search`` lets the model search the live web, scrape pages it finds, +and ground its answer with citations. Implemented as a Responses API +call with ``tools: [{"type": "web_search", ...}]`` so the model knows it +must use the built-in web tool to answer. + +The xAI Responses API doc currently states: *"only functions and web +search are supported as tools"* — so this is the canonical way to wire +xAI-native web search into Hermes without going through a third-party +search provider. + +Reference: https://docs.x.ai/docs/api-reference#responses-create +""" +from __future__ import annotations + +import json +import logging +import os +import time +from typing import Any, Dict, List, Optional + +import requests + +from tools.registry import registry, tool_error +from tools.xai_http import hermes_xai_user_agent + + +logger = logging.getLogger(__name__) + + +DEFAULT_XAI_BASE_URL = "https://api.x.ai/v1" +DEFAULT_WEB_SEARCH_MODEL = "grok-4.3" +DEFAULT_WEB_SEARCH_TIMEOUT_SECONDS = 180 +DEFAULT_WEB_SEARCH_RETRIES = 2 +MAX_WEBSITES = 10 + + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +def _get_xai_base_url() -> str: + return (os.getenv("XAI_BASE_URL") or DEFAULT_XAI_BASE_URL).strip().rstrip("/") + + +def _load_web_search_config() -> Dict[str, Any]: + try: + from hermes_cli.config import load_config + return load_config().get("web_search", {}) or {} + except Exception: + return {} + + +def _get_web_search_model() -> str: + cfg = _load_web_search_config() + return (cfg.get("model") or DEFAULT_WEB_SEARCH_MODEL).strip() + + +def _get_web_search_timeout_seconds() -> int: + cfg = _load_web_search_config() + raw = cfg.get("timeout_seconds", DEFAULT_WEB_SEARCH_TIMEOUT_SECONDS) + try: + return max(30, int(raw)) + except Exception: + return DEFAULT_WEB_SEARCH_TIMEOUT_SECONDS + + +def _get_web_search_retries() -> int: + cfg = _load_web_search_config() + raw = cfg.get("retries", DEFAULT_WEB_SEARCH_RETRIES) + try: + return max(0, int(raw)) + except Exception: + return DEFAULT_WEB_SEARCH_RETRIES + + +def check_web_search_requirements() -> bool: + return bool(os.getenv("XAI_API_KEY", "").strip()) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _normalize_websites(websites: Optional[List[str]], field_name: str) -> List[str]: + """Normalize a list of website hostnames (strip protocol + path).""" + cleaned: List[str] = [] + for site in websites or []: + s = str(site or "").strip() + # Strip http(s):// + for prefix in ("https://", "http://"): + if s.startswith(prefix): + s = s[len(prefix):] + # Strip path + s = s.split("/", 1)[0] + # Strip leading www. for stable matching + if s.startswith("www."): + s = s[4:] + s = s.strip() + if s: + cleaned.append(s) + if len(cleaned) > MAX_WEBSITES: + raise ValueError(f"{field_name} supports at most {MAX_WEBSITES} websites") + return cleaned + + +def _extract_response_text(payload: Dict[str, Any]) -> str: + """Best-effort extraction of plain answer text from Responses API body.""" + output_text = str(payload.get("output_text") or "").strip() + if output_text: + return output_text + + parts: List[str] = [] + for item in payload.get("output", []) or []: + if item.get("type") != "message": + continue + for content in item.get("content", []) or []: + ctype = content.get("type") + if ctype in ("output_text", "text"): + text = str(content.get("text") or "").strip() + if text: + parts.append(text) + return "\n\n".join(parts).strip() + + +def _extract_inline_citations(payload: Dict[str, Any]) -> List[Dict[str, Any]]: + """Pull url_citation annotations out of output[*].content[*].annotations.""" + citations: List[Dict[str, Any]] = [] + for item in payload.get("output", []) or []: + if item.get("type") != "message": + continue + for content in item.get("content", []) or []: + for annotation in content.get("annotations", []) or []: + if annotation.get("type") == "url_citation": + citations.append({ + "url": annotation.get("url", ""), + "title": annotation.get("title", ""), + "start_index": annotation.get("start_index"), + "end_index": annotation.get("end_index"), + }) + return citations + + +def _http_error_message(exc: requests.HTTPError) -> str: + resp = getattr(exc, "response", None) + if resp is None: + return str(exc) + try: + body = resp.text or "" + except Exception: + body = "" + code = getattr(resp, "status_code", "?") + return f"HTTP {code}: {body[:300]}" + + +# --------------------------------------------------------------------------- +# Public entry point +# --------------------------------------------------------------------------- + +def web_search_tool( + query: str, + allowed_websites: Optional[List[str]] = None, + excluded_websites: Optional[List[str]] = None, + from_date: str = "", + to_date: str = "", + country: str = "", +) -> str: + """Search the live web via xAI's web_search built-in tool. + + Parameters + ---------- + query : str + Required search query. + allowed_websites : list of str, optional + Whitelist of hostnames the search must restrict to (max 10). + Mutually exclusive with ``excluded_websites``. + excluded_websites : list of str, optional + Blacklist of hostnames the search must avoid (max 10). + from_date, to_date : str, optional + ISO-8601 ``YYYY-MM-DD`` date filters. + country : str, optional + ISO-3166 alpha-2 country code (e.g. ``"FR"``, ``"US"``) to localize + results. + + Returns + ------- + str + JSON-encoded ``{success, provider, tool, model, query, answer, + citations, inline_citations}`` on success, or + ``{success: false, error, error_type}`` on failure. + """ + if not query or not query.strip(): + return tool_error("query is required for web_search", success=False, provider="xai", tool="web_search") + + api_key = os.getenv("XAI_API_KEY", "").strip() + if not api_key: + return tool_error("XAI_API_KEY is not set", success=False, provider="xai", tool="web_search") + + try: + allowed = _normalize_websites(allowed_websites, "allowed_websites") + excluded = _normalize_websites(excluded_websites, "excluded_websites") + if allowed and excluded: + return tool_error( + "allowed_websites and excluded_websites cannot be used together", + success=False, provider="xai", tool="web_search", + ) + + tool_def: Dict[str, Any] = {"type": "web_search"} + if allowed: + tool_def["allowed_websites"] = allowed + if excluded: + tool_def["excluded_websites"] = excluded + if from_date.strip(): + tool_def["from_date"] = from_date.strip() + if to_date.strip(): + tool_def["to_date"] = to_date.strip() + if country.strip(): + tool_def["country"] = country.strip().upper() + + payload = { + "model": _get_web_search_model(), + "input": [ + { + "role": "user", + "content": query.strip(), + } + ], + "tools": [tool_def], + "store": False, + } + + timeout_seconds = _get_web_search_timeout_seconds() + max_retries = _get_web_search_retries() + response: Optional[requests.Response] = None + for attempt in range(max_retries + 1): + try: + response = requests.post( + f"{_get_xai_base_url()}/responses", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "User-Agent": hermes_xai_user_agent(), + }, + json=payload, + timeout=timeout_seconds, + ) + response.raise_for_status() + break + except requests.HTTPError as e: + status_code = getattr(getattr(e, "response", None), "status_code", None) + if status_code is None or status_code < 500 or attempt >= max_retries: + raise + logger.warning( + "web_search upstream failure on attempt %s/%s: %s", + attempt + 1, + max_retries + 1, + _http_error_message(e), + ) + time.sleep(min(5.0, 1.5 * (attempt + 1))) + except (requests.ReadTimeout, requests.ConnectionError) as e: + if attempt >= max_retries: + raise + logger.warning( + "web_search transient failure on attempt %s/%s: %s", + attempt + 1, max_retries + 1, e, + ) + time.sleep(min(5.0, 1.5 * (attempt + 1))) + + if response is None: + raise RuntimeError("web_search request did not return a response") + + data = response.json() + answer = _extract_response_text(data) + citations = list(data.get("citations") or []) + inline_citations = _extract_inline_citations(data) + + return json.dumps( + { + "success": True, + "provider": "xai", + "tool": "web_search", + "model": payload["model"], + "query": query.strip(), + "answer": answer, + "citations": citations, + "inline_citations": inline_citations, + }, + ensure_ascii=False, + ) + except requests.HTTPError as e: + logger.error("web_search failed: %s", e, exc_info=True) + return json.dumps( + { + "success": False, + "provider": "xai", + "tool": "web_search", + "error": _http_error_message(e), + "error_type": type(e).__name__, + }, + ensure_ascii=False, + ) + except requests.ReadTimeout as e: + logger.error("web_search timed out: %s", e, exc_info=True) + return json.dumps( + { + "success": False, + "provider": "xai", + "tool": "web_search", + "error": f"xAI web_search timed out after {_get_web_search_timeout_seconds()} seconds", + "error_type": type(e).__name__, + }, + ensure_ascii=False, + ) + except Exception as e: + logger.error("web_search failed: %s", e, exc_info=True) + return json.dumps( + { + "success": False, + "provider": "xai", + "tool": "web_search", + "error": str(e), + "error_type": type(e).__name__, + }, + ensure_ascii=False, + ) + + +# --------------------------------------------------------------------------- +# Tool registration +# --------------------------------------------------------------------------- + +WEB_SEARCH_SCHEMA = { + "name": "xai_web_search", + "description": ( + "Search the live web via xAI's web_search built-in tool. Returns " + "an answer grounded in citations. Optional: allow/exclude lists of " + "websites (max 10 each, mutually exclusive), date range filters, " + "country code for localization." + ), + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Required search query.", + }, + "allowed_websites": { + "type": "array", + "items": {"type": "string"}, + "description": "Hostnames the search must restrict to (max 10).", + }, + "excluded_websites": { + "type": "array", + "items": {"type": "string"}, + "description": "Hostnames the search must avoid (max 10).", + }, + "from_date": { + "type": "string", + "description": "ISO-8601 YYYY-MM-DD lower bound on result dates.", + }, + "to_date": { + "type": "string", + "description": "ISO-8601 YYYY-MM-DD upper bound on result dates.", + }, + "country": { + "type": "string", + "description": "ISO-3166 alpha-2 country code (e.g. 'FR').", + }, + }, + "required": ["query"], + }, +} + + +def _handle_web_search_tool_call(args: Dict[str, Any], **_kw: Any) -> str: + return web_search_tool( + query=args.get("query", ""), + allowed_websites=args.get("allowed_websites"), + excluded_websites=args.get("excluded_websites"), + from_date=str(args.get("from_date") or ""), + to_date=str(args.get("to_date") or ""), + country=str(args.get("country") or ""), + ) + + +registry.register( + name="xai_web_search", + toolset="xai_web_search", + schema=WEB_SEARCH_SCHEMA, + handler=_handle_web_search_tool_call, + check_fn=check_web_search_requirements, + emoji="🌐", +) diff --git a/toolsets.py b/toolsets.py index 5e34a0548c87f..220d56a29ff31 100644 --- a/toolsets.py +++ b/toolsets.py @@ -70,6 +70,8 @@ "kanban_unblock", # Computer use (macOS, gated on cua-driver being installed via check_fn) "computer_use", + # xAI native web search via Responses API web_search tool (gated on XAI_API_KEY) + "xai_web_search", ] @@ -117,6 +119,17 @@ "includes": [] }, + "xai_web_search": { + "description": ( + "xAI-native web search via the Responses API web_search " + "built-in tool. Returns an answer grounded in citations; " + "alternative to the existing web_search tool when xAI is " + "available." + ), + "tools": ["xai_web_search"], + "includes": [] + }, + "terminal": { "description": "Terminal/command execution and process management tools", "tools": ["terminal", "process"], From db0860c8e48bfed99a125f0dcac17624d51b0712 Mon Sep 17 00:00:00 2001 From: Julien Talbot Date: Mon, 11 May 2026 10:50:35 +0400 Subject: [PATCH 2/2] fix(xai): align toolset defaults and model fallback --- hermes_cli/models.py | 13 +++---------- hermes_cli/tools_config.py | 5 ++++- website/docs/reference/toolsets-reference.md | 1 + 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 1dc8a7aca66af..3ad904aaa66d4 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -106,20 +106,13 @@ def _codex_curated_models() -> list[str]: # Static fallback for xAI when the models.dev disk cache is empty (fresh -# install, offline first run, etc.). Mirrors the xAI-direct model IDs from -# $HERMES_HOME/models_dev_cache.json as of 2026-04-28. Whenever xAI renames -# or retires a model, the disk cache picks it up on the next refresh and the -# fallback here only matters until that refresh lands. +# install, offline first run, etc.). Keep this list conservative: it should +# not advertise models scheduled for May 15, 2026 retirement. _XAI_STATIC_FALLBACK: list[str] = [ + "grok-4.3", "grok-4.20-0309-reasoning", "grok-4.20-0309-non-reasoning", "grok-4.20-multi-agent-0309", - "grok-4-1-fast", - "grok-4-1-fast-non-reasoning", - "grok-4-fast", - "grok-4-fast-non-reasoning", - "grok-4", - "grok-code-fast-1", ] diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index bc8dfe02a6b0b..e2450e4832047 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -82,7 +82,10 @@ # Toolsets that are OFF by default for new installs. # They're still in _HERMES_CORE_TOOLS (available at runtime if enabled), # but the setup checklist won't pre-select them for first-time users. -_DEFAULT_OFF_TOOLSETS = {"moa", "homeassistant", "rl", "spotify", "discord", "discord_admin", "video"} +_DEFAULT_OFF_TOOLSETS = { + "moa", "homeassistant", "rl", "spotify", "discord", "discord_admin", "video", + "xai_web_search", +} # Platform-scoped toolsets: only appear in the `hermes tools` checklist for # these platforms, and only resolve/save for these platforms. A toolset diff --git a/website/docs/reference/toolsets-reference.md b/website/docs/reference/toolsets-reference.md index 37bd5aae1d8fd..43668b3389256 100644 --- a/website/docs/reference/toolsets-reference.md +++ b/website/docs/reference/toolsets-reference.md @@ -82,6 +82,7 @@ Or in-session: | `vision` | `vision_analyze` | Image analysis via vision-capable models. | | `video` | `video_analyze` | Video analysis and understanding tools (opt-in, not in the default toolset — add explicitly via `--toolsets`). | | `web` | `web_extract`, `web_search` | Web search and page content extraction. | +| `xai_web_search` | `xai_web_search` | Search the live web via xAI's Responses API `web_search` tool with citations. Requires `XAI_API_KEY`; opt-in for new installs. | | `yuanbao` | `yb_query_group_info`, `yb_query_group_members`, `yb_search_sticker`, `yb_send_dm`, `yb_send_sticker` | Yuanbao DM/group actions and sticker search. Registered only on `hermes-yuanbao`. | ## Platform Toolsets