Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
This commit is contained in:
+30
-7
@@ -133,8 +133,19 @@ _CREDENTIAL_FILES = (
|
||||
r'(?:~|\$home|\$\{home\})/\.'
|
||||
r'(?:netrc|pgpass|npmrc|pypirc)\b'
|
||||
)
|
||||
# macOS: /etc, /var, /tmp, /home are symlinks to /private/{etc,var,tmp,home}.
|
||||
# A command written to target /private/etc/sudoers works identically to
|
||||
# /etc/sudoers on macOS but bypasses a plain "/etc/" pattern check. Match
|
||||
# both forms. Inspired by Claude Code 2.1.113's "dangerous path protection".
|
||||
_MACOS_PRIVATE_SYSTEM_PATH = r'/private/(?:etc|var|tmp|home)/'
|
||||
# System-config paths that should trigger approval for any write/edit,
|
||||
# collapsing /etc, its macOS /private/etc mirror, and /etc/sudoers.d/ into
|
||||
# one shared fragment so new DANGEROUS_PATTERNS stay consistent.
|
||||
_SYSTEM_CONFIG_PATH = (
|
||||
rf'(?:/etc/|{_MACOS_PRIVATE_SYSTEM_PATH})'
|
||||
)
|
||||
_SENSITIVE_WRITE_TARGET = (
|
||||
r'(?:/etc/|/dev/sd|'
|
||||
rf'(?:{_SYSTEM_CONFIG_PATH}|/dev/sd|'
|
||||
rf'{_SSH_SENSITIVE_PATH}|'
|
||||
rf'{_HERMES_ENV_PATH}|'
|
||||
rf'{_SHELL_RC_FILES}|'
|
||||
@@ -318,10 +329,17 @@ DANGEROUS_PATTERNS = [
|
||||
# *next* line to satisfy the negative lookahead, silently allowing DELETE without WHERE.
|
||||
(r'\bDELETE\s+FROM\b(?![^\n]*\bWHERE\b)', "SQL DELETE without WHERE"),
|
||||
(r'\bTRUNCATE\s+(TABLE)?\s*\w', "SQL TRUNCATE"),
|
||||
(r'>\s*/etc/', "overwrite system config"),
|
||||
(rf'>\s*{_SYSTEM_CONFIG_PATH}', "overwrite system config"),
|
||||
(r'\bsystemctl\s+(-[^\s]+\s+)*(stop|restart|disable|mask)\b', "stop/restart system service"),
|
||||
(r'\bkill\s+-9\s+-1\b', "kill all processes"),
|
||||
(r'\bpkill\s+-9\b', "force kill processes"),
|
||||
# killall with SIGKILL (parallel to pkill -9). Catches -9 / -KILL /
|
||||
# -s KILL / -SIGKILL forms, and also `killall -r <regex>` broad sweeps
|
||||
# that can wipe out unrelated processes by accident.
|
||||
# Inspired by Claude Code 2.1.113 expanded deny rules.
|
||||
(r'\bkillall\s+(-[^\s]*\s+)*-(9|KILL|SIGKILL)\b', "force kill processes (killall -KILL)"),
|
||||
(r'\bkillall\s+(-[^\s]*\s+)*-s\s+(KILL|SIGKILL|9)\b', "force kill processes (killall -s KILL)"),
|
||||
(r'\bkillall\s+(-[^\s]*\s+)*-r\b', "kill processes by regex (killall -r)"),
|
||||
(r':\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:', "fork bomb"),
|
||||
# Any shell invocation via -c or combined flags like -lc, -ic, etc.
|
||||
(r'\b(bash|sh|zsh|ksh)\s+-[^\s]*c(\s+|$)', "shell command via -c/-lc flag"),
|
||||
@@ -333,7 +351,11 @@ DANGEROUS_PATTERNS = [
|
||||
(rf'\btee\b.*["\']?{_PROJECT_SENSITIVE_WRITE_TARGET}["\']?{_COMMAND_TAIL}', "overwrite project env/config via tee"),
|
||||
(rf'>>?\s*["\']?{_PROJECT_SENSITIVE_WRITE_TARGET}["\']?{_COMMAND_TAIL}', "overwrite project env/config via redirection"),
|
||||
(r'\bxargs\s+.*\brm\b', "xargs with rm"),
|
||||
(r'\bfind\b.*-exec\s+(/\S*/)?rm\b', "find -exec rm"),
|
||||
# find -exec rm / -execdir rm — the -execdir variant (same semantics,
|
||||
# runs in the directory of each match) was previously missed. Claude
|
||||
# Code 2.1.113 tightened their equivalent find rule to stop auto-
|
||||
# approving -exec / -delete flags.
|
||||
(r'\bfind\b.*-exec(?:dir)?\s+(/\S*/)?rm\b', "find -exec/-execdir rm"),
|
||||
(r'\bfind\b.*-delete\b', "find -delete"),
|
||||
# Gateway lifecycle protection: prevent the agent from killing its own
|
||||
# gateway process. These commands trigger a gateway restart/stop that
|
||||
@@ -351,11 +373,12 @@ DANGEROUS_PATTERNS = [
|
||||
# to regex at detection time. Catch the structural pattern instead.
|
||||
(r'\bkill\b.*\$\(\s*pgrep\b', "kill process via pgrep expansion (self-termination)"),
|
||||
(r'\bkill\b.*`\s*pgrep\b', "kill process via backtick pgrep expansion (self-termination)"),
|
||||
# File copy/move/edit into sensitive system paths
|
||||
(r'\b(cp|mv|install)\b.*\s/etc/', "copy/move file into /etc/"),
|
||||
# File copy/move/edit into sensitive system paths (/etc/ and macOS
|
||||
# /private/etc/ mirror).
|
||||
(rf'\b(cp|mv|install)\b.*\s{_SYSTEM_CONFIG_PATH}', "copy/move file into system config path"),
|
||||
(rf'\b(cp|mv|install)\b.*\s["\']?{_PROJECT_SENSITIVE_WRITE_TARGET}["\']?{_COMMAND_TAIL}', "overwrite project env/config file"),
|
||||
(r'\bsed\s+-[^\s]*i.*\s/etc/', "in-place edit of system config"),
|
||||
(r'\bsed\s+--in-place\b.*\s/etc/', "in-place edit of system config (long flag)"),
|
||||
(rf'\bsed\s+-[^\s]*i.*\s{_SYSTEM_CONFIG_PATH}', "in-place edit of system config"),
|
||||
(rf'\bsed\s+--in-place\b.*\s{_SYSTEM_CONFIG_PATH}', "in-place edit of system config (long flag)"),
|
||||
# Script execution via heredoc — bypasses the -e/-c flag patterns above.
|
||||
# `python3 << 'EOF'` feeds arbitrary code via stdin without -c/-e flags.
|
||||
(r'\b(python[23]?|perl|ruby|node)\s+<<', "script execution via heredoc"),
|
||||
|
||||
+15
-1
@@ -2362,6 +2362,7 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict:
|
||||
configured_provider = str(cfg.get("provider") or "").strip() or None
|
||||
configured_base_url = str(cfg.get("base_url") or "").strip() or None
|
||||
configured_api_key = str(cfg.get("api_key") or "").strip() or None
|
||||
configured_api_mode = str(cfg.get("api_mode") or "").strip().lower() or None
|
||||
|
||||
if configured_base_url:
|
||||
# When delegation.api_key is not set, return None so _build_child_agent
|
||||
@@ -2372,9 +2373,17 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict:
|
||||
# callers to duplicate the key under delegation.api_key.
|
||||
api_key = configured_api_key # None → inherited from parent in _build_child_agent
|
||||
|
||||
# Use the shared URL-based api_mode detector (same path the main agent's
|
||||
# runtime resolver uses) so Anthropic-compatible direct endpoints with a
|
||||
# /anthropic suffix — Azure AI Foundry, MiniMax, Zhipu GLM, LiteLLM
|
||||
# proxies — pick the right transport automatically. Without this,
|
||||
# subagents would default to chat_completions and hit 404s on endpoints
|
||||
# that only speak the Anthropic Messages protocol. Fixes #10213.
|
||||
from hermes_cli.runtime_provider import _detect_api_mode_for_url
|
||||
|
||||
base_lower = configured_base_url.lower()
|
||||
provider = "custom"
|
||||
api_mode = "chat_completions"
|
||||
api_mode = _detect_api_mode_for_url(configured_base_url) or "chat_completions"
|
||||
if (
|
||||
base_url_hostname(configured_base_url) == "chatgpt.com"
|
||||
and "/backend-api/codex" in base_lower
|
||||
@@ -2388,6 +2397,11 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict:
|
||||
provider = "custom"
|
||||
api_mode = "anthropic_messages"
|
||||
|
||||
# Explicit delegation.api_mode in config always wins. Lets users force
|
||||
# a transport for non-standard endpoints the URL heuristic can't detect.
|
||||
if configured_api_mode in {"chat_completions", "codex_responses", "anthropic_messages"}:
|
||||
api_mode = configured_api_mode
|
||||
|
||||
return {
|
||||
"model": configured_model,
|
||||
"provider": provider,
|
||||
|
||||
+2
-2
@@ -78,7 +78,7 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = {
|
||||
# ─── Inference providers ───────────────────────────────────────────────
|
||||
# Native Anthropic SDK — needed when provider=anthropic (not via
|
||||
# OpenRouter / aggregators which use the openai SDK).
|
||||
"provider.anthropic": ("anthropic==0.86.0",),
|
||||
"provider.anthropic": ("anthropic==0.87.0",), # CVE-2026-34450, CVE-2026-34452
|
||||
# AWS Bedrock provider
|
||||
"provider.bedrock": ("boto3==1.42.89",),
|
||||
|
||||
@@ -125,7 +125,7 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = {
|
||||
"platform.slack": (
|
||||
"slack-bolt==1.27.0",
|
||||
"slack-sdk==3.40.1",
|
||||
"aiohttp==3.13.3",
|
||||
"aiohttp==3.13.4", # CVE-2026-34513/34518/34519/34520/34525
|
||||
),
|
||||
"platform.matrix": (
|
||||
"mautrix[encryption]==0.21.0",
|
||||
|
||||
+38
-1
@@ -24,6 +24,7 @@ Example config::
|
||||
args: ["-y", "@modelcontextprotocol/server-github"]
|
||||
env:
|
||||
GITHUB_PERSONAL_ACCESS_TOKEN: "ghp_..."
|
||||
supports_parallel_tool_calls: true # tools from this server may run concurrently
|
||||
remote_api:
|
||||
url: "https://my-mcp-server.example.com/mcp"
|
||||
headers:
|
||||
@@ -56,6 +57,8 @@ Features:
|
||||
- Thread-safe architecture with dedicated background event loop
|
||||
- Sampling support: MCP servers can request LLM completions via
|
||||
sampling/createMessage (text and tool-use responses)
|
||||
- Parallel tool call opt-in: per-server ``supports_parallel_tool_calls``
|
||||
flag allows concurrent execution of tools from the same server
|
||||
|
||||
Architecture:
|
||||
A dedicated background event loop (_mcp_loop) runs in a daemon thread.
|
||||
@@ -1976,11 +1979,16 @@ def _handle_session_expired_and_retry(
|
||||
return None
|
||||
|
||||
|
||||
# Sanitized server names whose ``supports_parallel_tool_calls`` config is True.
|
||||
# Populated during ``register_mcp_servers()`` and queried by
|
||||
# ``is_mcp_tool_parallel_safe()`` for the parallel-execution check in run_agent.
|
||||
_parallel_safe_servers: set = set()
|
||||
|
||||
# Dedicated event loop running in a background daemon thread.
|
||||
_mcp_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
_mcp_thread: Optional[threading.Thread] = None
|
||||
|
||||
# Protects _mcp_loop, _mcp_thread, _servers, and _stdio_pids.
|
||||
# Protects _mcp_loop, _mcp_thread, _servers, _parallel_safe_servers, and _stdio_pids.
|
||||
_lock = threading.Lock()
|
||||
|
||||
# PIDs of stdio MCP server subprocesses. Tracked so we can force-kill
|
||||
@@ -3098,6 +3106,12 @@ def register_mcp_servers(servers: Dict[str, dict]) -> List[str]:
|
||||
for k, v in servers.items()
|
||||
if k not in _servers and _parse_boolish(v.get("enabled", True), default=True)
|
||||
}
|
||||
# Track which servers opt-in to parallel tool calls (idempotent).
|
||||
for srv_name, srv_cfg in servers.items():
|
||||
if _parse_boolish(srv_cfg.get("supports_parallel_tool_calls", False), default=False):
|
||||
_parallel_safe_servers.add(sanitize_mcp_name_component(srv_name))
|
||||
else:
|
||||
_parallel_safe_servers.discard(sanitize_mcp_name_component(srv_name))
|
||||
|
||||
if not new_servers:
|
||||
return _existing_tool_names()
|
||||
@@ -3208,6 +3222,29 @@ def discover_mcp_tools() -> List[str]:
|
||||
return tool_names
|
||||
|
||||
|
||||
def is_mcp_tool_parallel_safe(tool_name: str) -> bool:
|
||||
"""Check if an MCP tool belongs to a server that supports parallel tool calls.
|
||||
|
||||
MCP tool names follow the pattern ``mcp_{server}_{tool}``. This extracts
|
||||
the server component and checks it against the set of servers whose config
|
||||
includes ``supports_parallel_tool_calls: true``.
|
||||
|
||||
Returns False for non-MCP tools or tools from servers without the flag.
|
||||
"""
|
||||
if not tool_name.startswith("mcp_"):
|
||||
return False
|
||||
# Strip the "mcp_" prefix and extract the server name.
|
||||
# Tool names are: mcp_{sanitized_server}_{sanitized_tool}
|
||||
# We need to check all possible server prefixes because the server name
|
||||
# itself may contain underscores after sanitization.
|
||||
rest = tool_name[4:] # strip "mcp_"
|
||||
with _lock:
|
||||
for server_name in _parallel_safe_servers:
|
||||
if rest.startswith(server_name + "_") and len(rest) > len(server_name) + 1:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_mcp_status() -> List[dict]:
|
||||
"""Return status of all configured MCP servers for banner display.
|
||||
|
||||
|
||||
+30
-4
@@ -244,8 +244,16 @@ class ToolRegistry:
|
||||
emoji: str = "",
|
||||
max_result_size_chars: int | float | None = None,
|
||||
dynamic_schema_overrides: Callable = None,
|
||||
override: bool = False,
|
||||
):
|
||||
"""Register a tool. Called at module-import time by each tool file."""
|
||||
"""Register a tool. Called at module-import time by each tool file.
|
||||
|
||||
``override=True`` is an explicit opt-in for plugins that intend to
|
||||
replace an existing built-in tool implementation (e.g. swap the
|
||||
default browser tool for a headed-Chrome CDP backend). Without it,
|
||||
registrations that would shadow an existing tool from a different
|
||||
toolset are rejected to prevent accidental overwrites.
|
||||
"""
|
||||
with self._lock:
|
||||
existing = self._tools.get(name)
|
||||
if existing and existing.toolset != toolset:
|
||||
@@ -260,13 +268,22 @@ class ToolRegistry:
|
||||
"Tool '%s': MCP toolset '%s' overwriting MCP toolset '%s'",
|
||||
name, toolset, existing.toolset,
|
||||
)
|
||||
elif override:
|
||||
# Explicit plugin opt-in: replace the existing tool.
|
||||
# Logged at INFO so the override is auditable in agent.log.
|
||||
logger.info(
|
||||
"Tool '%s': toolset '%s' overriding existing toolset '%s' "
|
||||
"(override=True opt-in)",
|
||||
name, toolset, existing.toolset,
|
||||
)
|
||||
else:
|
||||
# Reject shadowing — prevent plugins/MCP from overwriting
|
||||
# built-in tools or vice versa.
|
||||
logger.error(
|
||||
"Tool registration REJECTED: '%s' (toolset '%s') would "
|
||||
"shadow existing tool from toolset '%s'. Deregister the "
|
||||
"existing tool first if this is intentional.",
|
||||
"shadow existing tool from toolset '%s'. Pass "
|
||||
"override=True to register() if the replacement is "
|
||||
"intentional, or deregister the existing tool first.",
|
||||
name, toolset, existing.toolset,
|
||||
)
|
||||
return
|
||||
@@ -387,7 +404,16 @@ class ToolRegistry:
|
||||
return entry.handler(args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.exception("Tool %s dispatch error: %s", name, e)
|
||||
return json.dumps({"error": f"Tool execution failed: {type(e).__name__}: {e}"})
|
||||
# Route through the sanitizer so framing tokens / CDATA / fences
|
||||
# in exception strings don't reach the model as structural noise.
|
||||
# See model_tools._sanitize_tool_error for rationale.
|
||||
raw = f"Tool execution failed: {type(e).__name__}: {e}"
|
||||
try:
|
||||
from model_tools import _sanitize_tool_error
|
||||
sanitized = _sanitize_tool_error(raw)
|
||||
except Exception:
|
||||
sanitized = raw # defensive: never let the sanitizer block error propagation
|
||||
return json.dumps({"error": sanitized})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Query helpers (replace redundant dicts in model_tools.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,
|
||||
)
|
||||
Reference in New Issue
Block a user