diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 508de0d3faa..5419ef92b4c 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1603,6 +1603,23 @@ DEFAULT_CONFIG = { "servers": {}, }, + # X (Twitter) Search via xAI's built-in x_search Responses tool. + # The tool registers when xAI credentials are available (SuperGrok + # OAuth or XAI_API_KEY) AND the x_search toolset is enabled in + # `hermes tools`. These settings tune the backing Responses API call. + "x_search": { + # xAI model used for the Responses call. grok-4.20-reasoning is + # the recommended default; any Grok model with x_search tool + # access works. + "model": "grok-4.20-reasoning", + # Request timeout in seconds (minimum 30). x_search can take + # 60-120s for complex queries — the default is generous. + "timeout_seconds": 180, + # Number of automatic retries on 5xx / ReadTimeout / ConnectionError. + # Each retry backs off (1.5x attempt seconds, capped at 5s). + "retries": 2, + }, + # Config schema version - bump this when adding new required fields "_config_version": 23, } diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 377194589ea..074bd04aa64 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -61,6 +61,7 @@ CONFIGURABLE_TOOLSETS = [ ("video", "🎬 Video Analysis", "video_analyze (requires video-capable model)"), ("image_gen", "🎨 Image Generation", "image_generate"), ("video_gen", "🎬 Video Generation", "video_generate (text-to-video + image-to-video)"), + ("x_search", "🐦 X (Twitter) Search", "x_search (requires xAI OAuth or XAI_API_KEY)"), ("moa", "🧠 Mixture of Agents", "mixture_of_agents"), ("tts", "🔊 Text-to-Speech", "text_to_speech"), ("skills", "📚 Skills", "list, view, manage"), @@ -86,7 +87,12 @@ CONFIGURABLE_TOOLSETS = [ # Video gen is off by default — it's a niche, paid, slow feature. Users # who want it opt in via `hermes tools` → Video Generation, which walks # them through provider + model selection. -_DEFAULT_OFF_TOOLSETS = {"moa", "homeassistant", "spotify", "discord", "discord_admin", "video", "video_gen"} +# +# X search is off by default — gated on xAI credentials (SuperGrok OAuth +# or XAI_API_KEY). Users opt in via `hermes tools` → X (Twitter) Search, +# which walks them through credential setup. The tool's check_fn means +# the schema won't appear to the model even if enabled without credentials. +_DEFAULT_OFF_TOOLSETS = {"moa", "homeassistant", "spotify", "discord", "discord_admin", "video", "video_gen", "x_search"} # Platform-scoped toolsets: only appear in the `hermes tools` checklist for # these platforms, and only resolve/save for these platforms. A toolset @@ -308,6 +314,39 @@ TOOL_CATEGORIES = { # converge image_gen toward. "providers": [], }, + "x_search": { + "name": "X (Twitter) Search", + "setup_title": "Select xAI Credential Source", + "setup_note": ( + "Hermes routes X searches through xAI's built-in x_search " + "Responses tool. Both credential sources hit the same " + "https://api.x.ai/v1/responses endpoint — pick whichever you " + "already have. SuperGrok OAuth is preferred when both are set " + "(uses your subscription quota instead of API spend)." + ), + "icon": "🐦", + "providers": [ + { + "name": "xAI Grok OAuth (SuperGrok Subscription)", + "badge": "subscription", + "tag": "Browser login at accounts.x.ai — no API key required", + "env_vars": [], + "post_setup": "xai_grok", + }, + { + "name": "xAI API key", + "badge": "paid", + "tag": "Direct xAI API billing via XAI_API_KEY", + "env_vars": [ + { + "key": "XAI_API_KEY", + "prompt": "xAI API key", + "url": "https://console.x.ai/", + }, + ], + }, + ], + }, "browser": { "name": "Browser Automation", "icon": "🌐", diff --git a/tests/tools/test_x_search_tool.py b/tests/tools/test_x_search_tool.py new file mode 100644 index 00000000000..7cbc4841a8a --- /dev/null +++ b/tests/tools/test_x_search_tool.py @@ -0,0 +1,438 @@ +"""Tests for the X (Twitter) Search tool backed by xAI Responses API. + +Covers: +- HTTP request shape (URL, headers, payload, model from config) +- Handle filter validation (allowed vs excluded mutual exclusion) +- Inline url_citation extraction from message annotations +- Structured error handling (4xx with code, 5xx retry, ReadTimeout retry) +- Credential resolution: API key path, OAuth path, both-set preference, none-set +- check_x_search_requirements gating in registry +""" + +import json + +import requests + + +class _FakeResponse: + def __init__(self, payload, *, status_code=200, text=None): + self._payload = payload + self.status_code = status_code + self.text = text if text is not None else json.dumps(payload) + + def raise_for_status(self): + if self.status_code >= 400: + err = requests.HTTPError(f"{self.status_code} Client Error") + err.response = self + raise err + + def json(self): + return self._payload + + +# --------------------------------------------------------------------------- +# Original PR #10786 test coverage (HTTP shape, handle validation, citations, +# retry behavior) — preserved verbatim. Uses XAI_API_KEY env var via the +# default resolver path. +# --------------------------------------------------------------------------- + +def test_x_search_posts_responses_request(monkeypatch): + from tools.x_search_tool import x_search_tool + from hermes_cli import __version__ + + captured = {} + + def _fake_post(url, headers=None, json=None, timeout=None): + captured["url"] = url + captured["headers"] = headers + captured["json"] = json + captured["timeout"] = timeout + return _FakeResponse( + { + "output_text": "People on X are discussing xAI's latest launch.", + "citations": [{"url": "https://x.com/example/status/1", "title": "Example post"}], + } + ) + + monkeypatch.setenv("XAI_API_KEY", "xai-test-key") + monkeypatch.setattr("requests.post", _fake_post) + + result = json.loads( + x_search_tool( + query="What are people saying about xAI on X?", + allowed_x_handles=["xai", "@grok"], + from_date="2026-04-01", + to_date="2026-04-10", + enable_image_understanding=True, + ) + ) + + tool_def = captured["json"]["tools"][0] + assert captured["url"] == "https://api.x.ai/v1/responses" + assert captured["headers"]["User-Agent"] == f"Hermes-Agent/{__version__}" + assert captured["json"]["model"] == "grok-4.20-reasoning" + assert captured["json"]["store"] is False + assert tool_def["type"] == "x_search" + assert tool_def["allowed_x_handles"] == ["xai", "grok"] + assert tool_def["from_date"] == "2026-04-01" + assert tool_def["to_date"] == "2026-04-10" + assert tool_def["enable_image_understanding"] is True + assert result["success"] is True + assert result["answer"] == "People on X are discussing xAI's latest launch." + + +def test_x_search_rejects_conflicting_handle_filters(monkeypatch): + from tools.x_search_tool import x_search_tool + + monkeypatch.setenv("XAI_API_KEY", "xai-test-key") + + result = json.loads( + x_search_tool( + query="latest xAI discussion", + allowed_x_handles=["xai"], + excluded_x_handles=["grok"], + ) + ) + + assert result["error"] == "allowed_x_handles and excluded_x_handles cannot be used together" + + +def test_x_search_extracts_inline_url_citations(monkeypatch): + from tools.x_search_tool import x_search_tool + + def _fake_post(url, headers=None, json=None, timeout=None): + return _FakeResponse( + { + "output": [ + { + "type": "message", + "content": [ + { + "type": "output_text", + "text": "xAI posted an update on X.", + "annotations": [ + { + "type": "url_citation", + "url": "https://x.com/xai/status/123", + "title": "xAI update", + "start_index": 0, + "end_index": 3, + } + ], + } + ], + } + ] + } + ) + + monkeypatch.setenv("XAI_API_KEY", "xai-test-key") + monkeypatch.setattr("requests.post", _fake_post) + + result = json.loads(x_search_tool(query="latest post from xai")) + + assert result["success"] is True + assert result["answer"] == "xAI posted an update on X." + assert result["inline_citations"] == [ + { + "url": "https://x.com/xai/status/123", + "title": "xAI update", + "start_index": 0, + "end_index": 3, + } + ] + + +def test_x_search_returns_structured_http_error(monkeypatch): + from tools.x_search_tool import x_search_tool + + class _FailingResponse: + status_code = 403 + text = '{"code":"forbidden","error":"x_search is not enabled for this model"}' + + def json(self): + return { + "code": "forbidden", + "error": "x_search is not enabled for this model", + } + + def raise_for_status(self): + err = requests.HTTPError("403 Client Error: Forbidden") + err.response = self + raise err + + monkeypatch.setenv("XAI_API_KEY", "xai-test-key") + monkeypatch.setattr("requests.post", lambda *a, **k: _FailingResponse()) + + result = json.loads(x_search_tool(query="latest xai discussion")) + + assert result["success"] is False + assert result["provider"] == "xai" + assert result["tool"] == "x_search" + assert result["error_type"] == "HTTPError" + assert result["error"] == "forbidden: x_search is not enabled for this model" + + +def test_x_search_retries_read_timeout_then_succeeds(monkeypatch): + from tools.x_search_tool import x_search_tool + + calls = {"count": 0} + + def _fake_post(url, headers=None, json=None, timeout=None): + calls["count"] += 1 + if calls["count"] == 1: + raise requests.ReadTimeout("timed out") + return _FakeResponse( + { + "output_text": "Recovered after retry.", + "citations": [], + } + ) + + monkeypatch.setenv("XAI_API_KEY", "xai-test-key") + monkeypatch.setattr("requests.post", _fake_post) + monkeypatch.setattr("tools.x_search_tool.time.sleep", lambda *_: None) + + result = json.loads(x_search_tool(query="grok xai")) + + assert calls["count"] == 2 + assert result["success"] is True + assert result["answer"] == "Recovered after retry." + + +def test_x_search_retries_5xx_then_succeeds(monkeypatch): + from tools.x_search_tool import x_search_tool + + calls = {"count": 0} + + def _fake_post(url, headers=None, json=None, timeout=None): + calls["count"] += 1 + if calls["count"] == 1: + return _FakeResponse( + {"code": "Internal error", "error": "Service temporarily unavailable."}, + status_code=500, + ) + return _FakeResponse({"output_text": "Recovered after 5xx retry."}) + + monkeypatch.setenv("XAI_API_KEY", "xai-test-key") + monkeypatch.setattr("requests.post", _fake_post) + monkeypatch.setattr("tools.x_search_tool.time.sleep", lambda *_: None) + + result = json.loads(x_search_tool(query="grok xai")) + + assert calls["count"] == 2 + assert result["success"] is True + assert result["answer"] == "Recovered after 5xx retry." + + +# --------------------------------------------------------------------------- +# Credential-resolution coverage — the OAuth-or-API-key gating contract. +# --------------------------------------------------------------------------- + +def _no_xai_env(monkeypatch): + """Strip any XAI_* env vars so the resolver doesn't see a leaked dev key.""" + for var in ("XAI_API_KEY", "XAI_BASE_URL", "HERMES_XAI_BASE_URL"): + monkeypatch.delenv(var, raising=False) + + +def test_x_search_uses_xai_oauth_when_only_oauth_available(monkeypatch): + """OAuth-only user: credential_source should be ``xai-oauth``.""" + from tools.registry import invalidate_check_fn_cache + from tools.x_search_tool import check_x_search_requirements, x_search_tool + + _no_xai_env(monkeypatch) + + def _fake_resolve(): + return { + "provider": "xai-oauth", + "api_key": "oauth-bearer-token", + "base_url": "https://api.x.ai/v1", + } + + monkeypatch.setattr( + "tools.x_search_tool.resolve_xai_http_credentials", _fake_resolve + ) + invalidate_check_fn_cache() + + assert check_x_search_requirements() is True + + captured = {} + + def _fake_post(url, headers=None, json=None, timeout=None): + captured["headers"] = headers + return _FakeResponse({"output_text": "Found posts via OAuth."}) + + monkeypatch.setattr("requests.post", _fake_post) + + result = json.loads(x_search_tool(query="anything about xai")) + + assert result["success"] is True + assert result["credential_source"] == "xai-oauth" + assert captured["headers"]["Authorization"] == "Bearer oauth-bearer-token" + + +def test_x_search_uses_api_key_when_only_xai_api_key_set(monkeypatch): + """API-key-only user: credential_source should be ``xai``.""" + from tools.registry import invalidate_check_fn_cache + from tools.x_search_tool import check_x_search_requirements, x_search_tool + + _no_xai_env(monkeypatch) + + def _fake_resolve(): + # Real ``resolve_xai_http_credentials`` returns ``"xai"`` when it + # falls through to the XAI_API_KEY env var path. + return { + "provider": "xai", + "api_key": "raw-api-key", + "base_url": "https://api.x.ai/v1", + } + + monkeypatch.setattr( + "tools.x_search_tool.resolve_xai_http_credentials", _fake_resolve + ) + invalidate_check_fn_cache() + + assert check_x_search_requirements() is True + + captured = {} + + def _fake_post(url, headers=None, json=None, timeout=None): + captured["headers"] = headers + return _FakeResponse({"output_text": "Found posts via API key."}) + + monkeypatch.setattr("requests.post", _fake_post) + + result = json.loads(x_search_tool(query="anything")) + + assert result["success"] is True + assert result["credential_source"] == "xai" + assert captured["headers"]["Authorization"] == "Bearer raw-api-key" + + +def test_x_search_prefers_oauth_when_both_available(monkeypatch): + """Both credentials present: OAuth wins (matches Teknium's billing preference). + + The real ordering is implemented in ``tools.xai_http.resolve_xai_http_credentials`` + — OAuth runtime first, fallback OAuth resolver second, ``XAI_API_KEY`` third. + This test exercises the contract by having the resolver return the OAuth + bearer (the ``xai-oauth`` ``provider`` tag is the marker). + """ + from tools.registry import invalidate_check_fn_cache + from tools.x_search_tool import x_search_tool + + monkeypatch.setenv("XAI_API_KEY", "raw-api-key") + + # Mimic xai_http's preference: OAuth wins, so we return the OAuth tuple + # even though XAI_API_KEY is also set. + def _fake_resolve(): + return { + "provider": "xai-oauth", + "api_key": "oauth-bearer-token", + "base_url": "https://api.x.ai/v1", + } + + monkeypatch.setattr( + "tools.x_search_tool.resolve_xai_http_credentials", _fake_resolve + ) + invalidate_check_fn_cache() + + captured = {} + + def _fake_post(url, headers=None, json=None, timeout=None): + captured["headers"] = headers + return _FakeResponse({"output_text": "OAuth preferred."}) + + monkeypatch.setattr("requests.post", _fake_post) + + result = json.loads(x_search_tool(query="anything")) + + assert result["credential_source"] == "xai-oauth" + assert captured["headers"]["Authorization"] == "Bearer oauth-bearer-token" + + +def test_x_search_returns_tool_error_when_no_credentials(monkeypatch): + """No credentials anywhere: tool returns a clear error, not a 401 from xAI.""" + from tools.registry import invalidate_check_fn_cache + from tools.x_search_tool import check_x_search_requirements, x_search_tool + + _no_xai_env(monkeypatch) + + def _fake_resolve(): + return { + "provider": "xai", + "api_key": "", + "base_url": "https://api.x.ai/v1", + } + + monkeypatch.setattr( + "tools.x_search_tool.resolve_xai_http_credentials", _fake_resolve + ) + invalidate_check_fn_cache() + + assert check_x_search_requirements() is False + + # If a model somehow invokes the tool despite a False check_fn, the call + # surfaces a friendly error rather than an HTTP exception. + result = x_search_tool(query="anything") + assert "No xAI credentials available" in result + assert "hermes auth add xai-oauth" in result + + +def test_x_search_check_fn_false_when_resolver_raises(monkeypatch): + """Resolver exceptions (e.g. expired token + failed refresh) gate the tool out.""" + from tools.registry import invalidate_check_fn_cache + from tools.x_search_tool import check_x_search_requirements + + _no_xai_env(monkeypatch) + + def _boom(): + raise RuntimeError("token revoked and refresh failed") + + monkeypatch.setattr( + "tools.x_search_tool.resolve_xai_http_credentials", _boom + ) + invalidate_check_fn_cache() + + assert check_x_search_requirements() is False + + +def test_x_search_honors_config_model_and_timeout(monkeypatch, tmp_path): + """``x_search.model`` and ``x_search.timeout_seconds`` override the defaults.""" + from tools.x_search_tool import x_search_tool + + monkeypatch.setenv("XAI_API_KEY", "xai-test-key") + + # Patch the in-module config loader so tests don't touch ~/.hermes/config.yaml. + monkeypatch.setattr( + "tools.x_search_tool._load_x_search_config", + lambda: {"model": "grok-custom-test", "timeout_seconds": 45, "retries": 0}, + ) + + captured = {} + + def _fake_post(url, headers=None, json=None, timeout=None): + captured["model"] = json["model"] + captured["timeout"] = timeout + return _FakeResponse({"output_text": "Custom model OK."}) + + monkeypatch.setattr("requests.post", _fake_post) + + result = json.loads(x_search_tool(query="anything")) + + assert result["success"] is True + assert captured["model"] == "grok-custom-test" + assert captured["timeout"] == 45 + + +def test_x_search_registered_in_registry_with_check_fn(): + """The tool is registered under the x_search toolset with the gating check_fn.""" + import tools.x_search_tool # noqa: F401 — ensures registration runs + from tools.registry import registry + + entry = registry.get_entry("x_search") + assert entry is not None + assert entry.toolset == "x_search" + assert entry.check_fn is not None + assert entry.check_fn.__name__ == "check_x_search_requirements" + assert "XAI_API_KEY" in entry.requires_env + assert entry.emoji == "🐦" diff --git a/tools/x_search_tool.py b/tools/x_search_tool.py new file mode 100644 index 00000000000..8b242ee0ca8 --- /dev/null +++ b/tools/x_search_tool.py @@ -0,0 +1,424 @@ +#!/usr/bin/env python3 +"""X Search tool backed by xAI's built-in ``x_search`` Responses API tool. + +Authentication +-------------- +The tool registers when **either** xAI credential path is available: + +* ``XAI_API_KEY`` is set in ``~/.hermes/.env`` or the process environment + (paid xAI API key), OR +* The user is signed in via xAI Grok OAuth — SuperGrok subscription — + i.e. ``hermes auth add xai-oauth`` has been run and the stored refresh + token still works. + +Credential preference at call time matches +:func:`tools.xai_http.resolve_xai_http_credentials`: SuperGrok OAuth first, +direct OAuth resolver second, ``XAI_API_KEY`` last. That helper also +auto-refreshes the OAuth access token when it's within the refresh skew +window, so a ``True`` from :func:`check_x_search_requirements` means the +bearer is fetchable AND non-empty. + +Salvaged from PR #10786 (originally by @Jaaneek); credential resolution +reworked to honor both auth modes per Teknium's design. +""" + +from __future__ import annotations + +import json +import logging +import os +import time +from typing import Any, Dict, List, Optional, Tuple + +import requests + +from tools.registry import registry, tool_error +from tools.xai_http import hermes_xai_user_agent, resolve_xai_http_credentials + +logger = logging.getLogger(__name__) + +DEFAULT_XAI_BASE_URL = "https://api.x.ai/v1" +DEFAULT_X_SEARCH_MODEL = "grok-4.20-reasoning" +DEFAULT_X_SEARCH_TIMEOUT_SECONDS = 180 +DEFAULT_X_SEARCH_RETRIES = 2 +MAX_HANDLES = 10 + + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + +def _load_x_search_config() -> Dict[str, Any]: + try: + from hermes_cli.config import load_config + + return load_config().get("x_search", {}) or {} + except Exception: + return {} + + +def _get_x_search_model() -> str: + cfg = _load_x_search_config() + return (str(cfg.get("model") or "").strip() or DEFAULT_X_SEARCH_MODEL) + + +def _get_x_search_timeout_seconds() -> int: + cfg = _load_x_search_config() + raw_value = cfg.get("timeout_seconds", DEFAULT_X_SEARCH_TIMEOUT_SECONDS) + try: + return max(30, int(raw_value)) + except Exception: + return DEFAULT_X_SEARCH_TIMEOUT_SECONDS + + +def _get_x_search_retries() -> int: + cfg = _load_x_search_config() + raw_value = cfg.get("retries", DEFAULT_X_SEARCH_RETRIES) + try: + return max(0, int(raw_value)) + except Exception: + return DEFAULT_X_SEARCH_RETRIES + + +# --------------------------------------------------------------------------- +# Credential resolution +# --------------------------------------------------------------------------- + +def _resolve_xai_bearer() -> Tuple[str, str, str]: + """Return ``(api_key, base_url, source)``. + + ``source`` is one of ``"xai-oauth"`` or ``"xai"`` so callers (and tests) + can tell which credential path won. Raises ``RuntimeError`` if no usable + credential is available — the registered :func:`check_x_search_requirements` + gate makes that case unreachable in normal operation, but the runtime + check exists so a credential that expires between registration and + invocation produces a clean tool error instead of a 401. + """ + creds = resolve_xai_http_credentials() + api_key = str(creds.get("api_key") or "").strip() + if not api_key: + raise RuntimeError( + "No xAI credentials available. Run `hermes auth add xai-oauth` " + "to sign in with your SuperGrok subscription, or set XAI_API_KEY." + ) + base_url = str(creds.get("base_url") or DEFAULT_XAI_BASE_URL).strip().rstrip("/") + source = str(creds.get("provider") or "xai") + return api_key, base_url, source + + +def check_x_search_requirements() -> bool: + """Return True when xAI credentials are available AND valid. + + ``resolve_xai_http_credentials`` calls + :func:`hermes_cli.auth.resolve_xai_oauth_runtime_credentials` which + auto-refreshes the OAuth access token if it's expiring; a successful + return therefore implies a usable bearer. + """ + try: + creds = resolve_xai_http_credentials() + return bool(str(creds.get("api_key") or "").strip()) + except Exception: + return False + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _normalize_handles(handles: Optional[List[str]], field_name: str) -> List[str]: + cleaned: List[str] = [] + for handle in handles or []: + normalized = str(handle or "").strip().lstrip("@") + if normalized: + cleaned.append(normalized) + if len(cleaned) > MAX_HANDLES: + raise ValueError(f"{field_name} supports at most {MAX_HANDLES} handles") + return cleaned + + +def _extract_response_text(payload: Dict[str, Any]) -> str: + 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]]: + 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": + continue + 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: + response = getattr(exc, "response", None) + if response is None: + return str(exc) + + try: + payload = response.json() + except Exception: + payload = None + + if isinstance(payload, dict): + code = str(payload.get("code") or "").strip() + error = str(payload.get("error") or "").strip() + message = error or str(payload) + if code and code not in message: + message = f"{code}: {message}" + return message or str(exc) + + text = str(getattr(response, "text", "") or "").strip() + if text: + return text[:500] + return str(exc) + + +# --------------------------------------------------------------------------- +# Tool implementation +# --------------------------------------------------------------------------- + +def x_search_tool( + query: str, + allowed_x_handles: Optional[List[str]] = None, + excluded_x_handles: Optional[List[str]] = None, + from_date: str = "", + to_date: str = "", + enable_image_understanding: bool = False, + enable_video_understanding: bool = False, +) -> str: + if not query or not query.strip(): + return tool_error("query is required for x_search") + + try: + api_key, base_url, source = _resolve_xai_bearer() + except RuntimeError as exc: + return tool_error(str(exc)) + + try: + allowed = _normalize_handles(allowed_x_handles, "allowed_x_handles") + excluded = _normalize_handles(excluded_x_handles, "excluded_x_handles") + if allowed and excluded: + return tool_error("allowed_x_handles and excluded_x_handles cannot be used together") + + tool_def: Dict[str, Any] = {"type": "x_search"} + if allowed: + tool_def["allowed_x_handles"] = allowed + if excluded: + tool_def["excluded_x_handles"] = excluded + if from_date.strip(): + tool_def["from_date"] = from_date.strip() + if to_date.strip(): + tool_def["to_date"] = to_date.strip() + if enable_image_understanding: + tool_def["enable_image_understanding"] = True + if enable_video_understanding: + tool_def["enable_video_understanding"] = True + + payload = { + "model": _get_x_search_model(), + "input": [ + { + "role": "user", + "content": query.strip(), + } + ], + "tools": [tool_def], + "store": False, + } + + timeout_seconds = _get_x_search_timeout_seconds() + max_retries = _get_x_search_retries() + response: Optional[requests.Response] = None + for attempt in range(max_retries + 1): + try: + response = requests.post( + f"{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( + "x_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( + "x_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("x_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", + "credential_source": source, + "tool": "x_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("x_search failed: %s", e, exc_info=True) + return json.dumps( + { + "success": False, + "provider": "xai", + "tool": "x_search", + "error": _http_error_message(e), + "error_type": type(e).__name__, + }, + ensure_ascii=False, + ) + except requests.ReadTimeout as e: + logger.error("x_search timed out: %s", e, exc_info=True) + return json.dumps( + { + "success": False, + "provider": "xai", + "tool": "x_search", + "error": f"xAI x_search timed out after {_get_x_search_timeout_seconds()} seconds", + "error_type": type(e).__name__, + }, + ensure_ascii=False, + ) + except Exception as e: + logger.error("x_search failed: %s", e, exc_info=True) + return json.dumps( + { + "success": False, + "provider": "xai", + "tool": "x_search", + "error": str(e), + "error_type": type(e).__name__, + }, + ensure_ascii=False, + ) + + +X_SEARCH_SCHEMA = { + "name": "x_search", + "description": ( + "Search X (Twitter) posts, profiles, and threads using xAI's built-in " + "X Search tool. Use this for current discussion, reactions, or claims " + "on X rather than general web pages. Available when xAI credentials " + "are configured (SuperGrok OAuth or XAI_API_KEY)." + ), + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "What to look up on X.", + }, + "allowed_x_handles": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional list of X handles to include exclusively (max 10).", + }, + "excluded_x_handles": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional list of X handles to exclude (max 10).", + }, + "from_date": { + "type": "string", + "description": "Optional start date in YYYY-MM-DD format.", + }, + "to_date": { + "type": "string", + "description": "Optional end date in YYYY-MM-DD format.", + }, + "enable_image_understanding": { + "type": "boolean", + "description": "Whether xAI should analyze images attached to matching X posts.", + "default": False, + }, + "enable_video_understanding": { + "type": "boolean", + "description": "Whether xAI should analyze videos attached to matching X posts.", + "default": False, + }, + }, + "required": ["query"], + }, +} + + +def _handle_x_search(args, **kw): + return x_search_tool( + query=args.get("query", ""), + allowed_x_handles=args.get("allowed_x_handles"), + excluded_x_handles=args.get("excluded_x_handles"), + from_date=args.get("from_date", ""), + to_date=args.get("to_date", ""), + enable_image_understanding=bool(args.get("enable_image_understanding", False)), + enable_video_understanding=bool(args.get("enable_video_understanding", False)), + ) + + +registry.register( + name="x_search", + toolset="x_search", + schema=X_SEARCH_SCHEMA, + handler=_handle_x_search, + check_fn=check_x_search_requirements, + requires_env=["XAI_API_KEY"], + emoji="🐦", + max_result_size_chars=100_000, +) diff --git a/toolsets.py b/toolsets.py index 8ec45f11a2f..5de07e4c7a1 100644 --- a/toolsets.py +++ b/toolsets.py @@ -88,6 +88,17 @@ TOOLSETS = { "tools": ["web_search"], "includes": [] }, + + "x_search": { + "description": ( + "Search X (Twitter) posts and threads via xAI's built-in " + "x_search Responses tool. Available when xAI credentials are " + "configured (SuperGrok OAuth or XAI_API_KEY). Off by default; " + "enable in `hermes tools` → X (Twitter) Search." + ), + "tools": ["x_search"], + "includes": [] + }, "vision": { "description": "Image analysis and vision tools", diff --git a/website/docs/guides/xai-grok-oauth.md b/website/docs/guides/xai-grok-oauth.md index 67d31c929ad..d85aa4c64bf 100644 --- a/website/docs/guides/xai-grok-oauth.md +++ b/website/docs/guides/xai-grok-oauth.md @@ -128,7 +128,7 @@ hermes --provider x-ai-oauth # alias hermes --provider xai-grok-oauth # alias ``` -## Direct-to-xAI Tools (TTS / Image / Video / Transcription) +## Direct-to-xAI Tools (TTS / Image / Video / Transcription / X Search) Once you're logged in via OAuth, every direct-to-xAI tool reuses the same bearer token automatically — there is **no separate setup** unless you'd rather use an API key. @@ -139,6 +139,7 @@ hermes tools # → Text-to-Speech → "xAI TTS" # → Image Generation → "xAI Grok Imagine (image)" # → Video Generation → "xAI Grok Imagine" +# → X (Twitter) Search → "xAI Grok OAuth (SuperGrok Subscription)" ``` If OAuth tokens are already stored, the picker confirms it and skips the credential prompt. If neither OAuth nor `XAI_API_KEY` is set, the picker offers a 3-choice menu: OAuth login, paste API key, or skip. @@ -147,6 +148,10 @@ If OAuth tokens are already stored, the picker confirms it and skips the credent The `video_gen` toolset is disabled by default. Enable it in `hermes tools` → `🎬 Video Generation` (press space) before the agent can call `video_generate`. Otherwise the agent may fall back to the bundled ComfyUI skill, which is also tagged for video generation. ::: +:::note X search is off by default +The `x_search` toolset is disabled by default. Enable it in `hermes tools` → `🐦 X (Twitter) Search` (press space) before the agent can call `x_search`. The tool routes through xAI's built-in `x_search` Responses API — it works with **either** your SuperGrok OAuth login or a paid `XAI_API_KEY`, and prefers OAuth when both are configured (uses your subscription quota instead of API spend). The tool schema is hidden from the model when no xAI credentials are configured, regardless of whether the toolset is enabled. +::: + ### Models | Tool | Model | Notes | diff --git a/website/docs/reference/tools-reference.md b/website/docs/reference/tools-reference.md index 03930264f8c..507bd307afb 100644 --- a/website/docs/reference/tools-reference.md +++ b/website/docs/reference/tools-reference.md @@ -196,6 +196,12 @@ Opt-in toolset (not loaded in the default `hermes-cli` set). Add via `--toolsets | `web_search` | Search the web for information. Returns up to 5 results by default with titles, URLs, and descriptions. Accepts an optional `limit` (1-100, default 5). The query is passed through to the configured backend, so operators such as `site:domain`, `filetype:pdf`, `intitle:word`, `-term`, and `"exact phrase"` may work when the backend supports them. | EXA_API_KEY or PARALLEL_API_KEY or FIRECRAWL_API_KEY or TAVILY_API_KEY | | `web_extract` | Extract content from web page URLs. Returns page content in markdown format. Also works with PDF URLs — pass the PDF link directly and it converts to markdown text. Pages under 5000 chars return full markdown; larger pages are LLM-summarized. | EXA_API_KEY or PARALLEL_API_KEY or FIRECRAWL_API_KEY or TAVILY_API_KEY | +## `x_search` toolset + +| Tool | Description | Requires environment | +|------|-------------|----------------------| +| `x_search` | Search X (Twitter) posts, profiles, and threads using xAI's built-in `x_search` Responses tool. Use this for current discussion, reactions, or claims on X rather than general web pages. Off by default — opt in via `hermes tools` → 🐦 X (Twitter) Search. Schema is only registered when xAI credentials are configured (check_fn-gated). | XAI_API_KEY **or** xAI Grok OAuth (SuperGrok Subscription) login | + ## `tts` toolset | Tool | Description | Requires environment | diff --git a/website/docs/reference/toolsets-reference.md b/website/docs/reference/toolsets-reference.md index 5bf1f14260e..61b51e4e30e 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. | +| `x_search` | `x_search` | Search X (Twitter) posts and threads via xAI's built-in `x_search` Responses tool. Off by default; opt in via `hermes tools`. Schema only registered when xAI credentials (SuperGrok OAuth or `XAI_API_KEY`) are configured. | | `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 diff --git a/website/docs/user-guide/features/tools.md b/website/docs/user-guide/features/tools.md index 9f9eddbb513..0c5dd30cb2c 100644 --- a/website/docs/user-guide/features/tools.md +++ b/website/docs/user-guide/features/tools.md @@ -21,6 +21,7 @@ High-level categories: | Category | Examples | Description | |----------|----------|-------------| | **Web** | `web_search`, `web_extract` | Search the web and extract page content. | +| **X Search** | `x_search` | Search X (Twitter) posts and threads via xAI's built-in `x_search` Responses tool — gated on xAI credentials (SuperGrok OAuth or `XAI_API_KEY`); off by default, opt in via `hermes tools` → 🐦 X (Twitter) Search. | | **Terminal & Files** | `terminal`, `process`, `read_file`, `patch` | Execute commands and manipulate files. | | **Browser** | `browser_navigate`, `browser_snapshot`, `browser_vision` | Interactive browser automation with text and vision support. | | **Media** | `vision_analyze`, `image_generate`, `text_to_speech` | Multimodal analysis and generation. | diff --git a/website/docs/user-guide/features/x-search.md b/website/docs/user-guide/features/x-search.md new file mode 100644 index 00000000000..c01bb8adf6d --- /dev/null +++ b/website/docs/user-guide/features/x-search.md @@ -0,0 +1,117 @@ +--- +title: X (Twitter) Search +description: Search X (Twitter) posts and threads from within the agent using xAI's built-in x_search Responses tool — works with either a SuperGrok OAuth login or an XAI_API_KEY. +sidebar_label: X (Twitter) Search +sidebar_position: 7 +--- + +# X (Twitter) Search + +The `x_search` tool lets the agent search X (Twitter) posts, profiles, and threads directly. It's backed by xAI's built-in `x_search` tool on the Responses API at `https://api.x.ai/v1/responses` — Grok itself runs the search server-side and returns synthesized results with citations to the originating posts. + +**Use this instead of `web_search`** when you specifically want current discussion, reactions, or claims **on X**. For general web pages, keep using `web_search` / `web_extract`. + +## Authentication + +`x_search` registers when **either** xAI credential path is available: + +| Credential | Source | Setup | +|------------|--------|-------| +| **SuperGrok OAuth** (preferred) | Browser login at `accounts.x.ai`, refreshed automatically | `hermes auth add xai-oauth` — see [xAI Grok OAuth (SuperGrok Subscription)](../../guides/xai-grok-oauth.md) | +| **`XAI_API_KEY`** | Paid xAI API key | Set in `~/.hermes/.env` | + +Both hit the same endpoint with the same payload — the only difference is the bearer token. **When both are configured, SuperGrok OAuth wins** so x_search runs against your subscription quota instead of paid API spend. + +The tool's `check_fn` runs the xAI credential resolver every time the model's tool list is rebuilt. A `True` return means the bearer is fetchable AND non-empty AND (if it had expired) successfully refreshed. Revoked tokens with a failed refresh hide the tool from the schema; the model simply can't see it. + +## Enabling the tool + +Off by default. Enable in `hermes tools`: + +```bash +hermes tools +# → 🐦 X (Twitter) Search (press space to toggle on) +``` + +The picker offers two credential choices: + +1. **xAI Grok OAuth (SuperGrok Subscription)** — opens the browser to `accounts.x.ai` if you're not already logged in +2. **xAI API key** — prompts for `XAI_API_KEY` + +Either choice satisfies the gating. You can pick whichever credentials you already have; the tool works identically with both. If both end up configured, OAuth is preferred at call time. + +## Configuration + +```yaml +# ~/.hermes/config.yaml +x_search: + # xAI model used for the Responses call. + # grok-4.20-reasoning is the recommended default; any Grok model + # with x_search tool access works. + model: grok-4.20-reasoning + + # Request timeout in seconds. x_search can take 60–120s for + # complex queries — the default is generous. Minimum: 30. + timeout_seconds: 180 + + # Number of automatic retries on 5xx / ReadTimeout / ConnectionError. + # Each retry backs off (1.5x attempt seconds, capped at 5s). + retries: 2 +``` + +## Tool parameters + +The agent calls `x_search` with these arguments: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `query` | string (required) | What to look up on X. | +| `allowed_x_handles` | string array | Optional list of handles to include **exclusively** (max 10). Leading `@` is stripped. | +| `excluded_x_handles` | string array | Optional list of handles to exclude (max 10). Mutually exclusive with `allowed_x_handles`. | +| `from_date` | string | Optional `YYYY-MM-DD` start date. | +| `to_date` | string | Optional `YYYY-MM-DD` end date. | +| `enable_image_understanding` | boolean | Ask xAI to analyze images attached to matching posts. | +| `enable_video_understanding` | boolean | Ask xAI to analyze videos attached to matching posts. | + +The tool returns JSON with: + +- `answer` — synthesized text response from Grok +- `citations` — citations returned by the Responses API top-level field +- `inline_citations` — `url_citation` annotations extracted from the message body (each with `url`, `title`, `start_index`, `end_index`) +- `credential_source` — `"xai-oauth"` if OAuth resolved, `"xai"` if API key resolved +- `model`, `query`, `provider`, `tool`, `success` + +## Example + +Talking to the agent: + +> What are people on X saying about the new Grok image features? Focus on responses from @xai. + +The agent will: + +1. Call `x_search` with `query="reactions to new Grok image features"`, `allowed_x_handles=["xai"]` +2. Get back a synthesized answer plus a list of citations linking to specific posts +3. Reply with the answer and references + +## Troubleshooting + +### "No xAI credentials available" + +The tool surfaces this when both auth paths fail. Either set `XAI_API_KEY` in `~/.hermes/.env` or run `hermes auth add xai-oauth` and complete the browser login. Then restart your session so the agent re-reads the tool registry. + +### "`x_search` is not enabled for this model" + +The configured `x_search.model` doesn't have access to the server-side `x_search` tool. Switch to `grok-4.20-reasoning` (the default) or another Grok model that supports it. Check the [xAI documentation](https://docs.x.ai/) for the current list. + +### Tool doesn't appear in the schema + +Two possible causes: + +1. **Toolset not enabled.** Run `hermes tools` and confirm `🐦 X (Twitter) Search` is checked. +2. **No xAI credentials.** The check_fn returns False, so the schema stays hidden. Run `hermes auth status` to confirm xai-oauth login state, and check that `XAI_API_KEY` is set (if you're using the API-key path). + +## See Also + +- [xAI Grok OAuth (SuperGrok Subscription)](../../guides/xai-grok-oauth.md) — the OAuth setup guide +- [Web Search & Extract](web-search.md) — for general (non-X) web search +- [Tools Reference](../../reference/tools-reference.md) — full tool catalog diff --git a/website/sidebars.ts b/website/sidebars.ts index 2f870a97696..f619f2318c9 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -83,6 +83,7 @@ const sidebars: SidebarsConfig = { items: [ 'user-guide/features/voice-mode', 'user-guide/features/web-search', + 'user-guide/features/x-search', 'user-guide/features/browser', 'user-guide/features/computer-use', 'user-guide/features/vision',