Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
# Conflicts: # tui_gateway/server.py
This commit is contained in:
+36
-1
@@ -76,9 +76,13 @@ except Exception:
|
||||
check_website_access = lambda url: None # noqa: E731 — fail-open if policy module unavailable
|
||||
|
||||
try:
|
||||
from tools.url_safety import is_safe_url as _is_safe_url
|
||||
from tools.url_safety import (
|
||||
is_safe_url as _is_safe_url,
|
||||
is_always_blocked_url as _is_always_blocked_url,
|
||||
)
|
||||
except Exception:
|
||||
_is_safe_url = lambda url: False # noqa: E731 — fail-closed: block all if safety module unavailable
|
||||
_is_always_blocked_url = lambda url: True # noqa: E731 — fail-closed on the floor too
|
||||
from tools.browser_providers.base import CloudBrowserProvider
|
||||
from tools.browser_providers.browserbase import BrowserbaseProvider
|
||||
from tools.browser_providers.browser_use import BrowserUseProvider
|
||||
@@ -837,6 +841,10 @@ def _url_is_private(url: str) -> bool:
|
||||
ip.is_private
|
||||
or ip.is_loopback
|
||||
or ip.is_link_local
|
||||
# 172.16.0.0/12: only covered by ip.is_private on Python
|
||||
# ≥3.11 (bpo-40791). Explicit check keeps 3.10 runtimes
|
||||
# routing these to the local sidecar correctly.
|
||||
or ip in ipaddress.ip_network("172.16.0.0/12")
|
||||
or ip in ipaddress.ip_network("100.64.0.0/10")
|
||||
)
|
||||
except ValueError:
|
||||
@@ -2081,6 +2089,18 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:
|
||||
nav_session_key = _navigation_session_key(effective_task_id, url)
|
||||
auto_local_this_nav = _is_local_sidecar_key(nav_session_key)
|
||||
|
||||
# Always-blocked floor: cloud metadata / IMDS endpoints are denied
|
||||
# regardless of backend, hybrid routing, or allow_private_urls.
|
||||
# There's no legitimate agent use case for navigating to
|
||||
# 169.254.169.254 / metadata.google.internal / ECS task metadata
|
||||
# via a browser, and routing those to a local Chromium sidecar
|
||||
# on an EC2/GCP/Azure host exfiltrates IAM credentials (#16234).
|
||||
if not _is_local_backend() and _is_always_blocked_url(url):
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": "Blocked: URL targets a cloud metadata endpoint",
|
||||
})
|
||||
|
||||
if (
|
||||
not _is_local_backend()
|
||||
and not auto_local_this_nav
|
||||
@@ -2143,6 +2163,21 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:
|
||||
# Skipped for local backends (same rationale as the pre-nav check),
|
||||
# and for the hybrid local sidecar (we're already on a local browser
|
||||
# hitting a private URL by design).
|
||||
# Always-blocked floor (cloud metadata / IMDS) is enforced even
|
||||
# when auto_local_this_nav is true — see pre-nav check for
|
||||
# rationale (#16234).
|
||||
if (
|
||||
not _is_local_backend()
|
||||
and final_url
|
||||
and final_url != url
|
||||
and _is_always_blocked_url(final_url)
|
||||
):
|
||||
_run_browser_command(nav_session_key, "open", ["about:blank"], timeout=10)
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": "Blocked: redirect landed on a cloud metadata endpoint",
|
||||
})
|
||||
|
||||
if (
|
||||
not _is_local_backend()
|
||||
and not auto_local_this_nav
|
||||
|
||||
@@ -374,6 +374,34 @@ def get_cache_directory_mounts(
|
||||
return mounts
|
||||
|
||||
|
||||
def to_agent_visible_cache_path(
|
||||
host_path: str,
|
||||
container_base: str = "/root/.hermes",
|
||||
) -> str:
|
||||
"""Translate a host cache path to its mounted path inside the sandbox.
|
||||
|
||||
Returns the input unchanged if it is not under any auto-mounted cache
|
||||
directory, or if the active terminal backend does not require path
|
||||
translation (only Docker for now).
|
||||
"""
|
||||
# Only Docker backend requires translation at this time. Other backends
|
||||
# (Modal, Daytona, Vercel) use different mount semantics and will be
|
||||
# addressed separately if needed. Backend is identified by TERMINAL_ENV
|
||||
# (same env var tools/terminal_tool.py reads in _get_environment_config).
|
||||
if os.environ.get("TERMINAL_ENV", "local") != "docker":
|
||||
return host_path
|
||||
|
||||
path = Path(host_path)
|
||||
for mount in get_cache_directory_mounts(container_base=container_base):
|
||||
host_dir = Path(mount["host_path"])
|
||||
try:
|
||||
rel = path.relative_to(host_dir)
|
||||
return str(Path(mount["container_path"]) / rel)
|
||||
except ValueError:
|
||||
continue
|
||||
return host_path
|
||||
|
||||
|
||||
def iter_cache_files(
|
||||
container_base: str = "/root/.hermes",
|
||||
) -> List[Dict[str, str]]:
|
||||
|
||||
+42
-7
@@ -462,6 +462,37 @@ def _is_mcp_toolset_name(name: str) -> bool:
|
||||
return bool(target and str(target).startswith("mcp-"))
|
||||
|
||||
|
||||
def _expand_parent_toolsets(parent_toolsets: set) -> set:
|
||||
"""Expand composite toolsets so individual toolset names are recognized.
|
||||
|
||||
When a parent uses a composite toolset like ``hermes-cli`` (which bundles
|
||||
all core tools), the child may request individual toolsets such as ``web``
|
||||
or ``terminal``. A simple name-based intersection would reject them
|
||||
because ``"web" != "hermes-cli"``.
|
||||
|
||||
This helper collects the tool names from each parent toolset, then adds
|
||||
the names of any individual toolsets whose tools are a *subset* of the
|
||||
parent's available tools. The original parent toolset names are preserved.
|
||||
"""
|
||||
parent_tool_names: set = set()
|
||||
for ts_name in parent_toolsets:
|
||||
ts_def = TOOLSETS.get(ts_name)
|
||||
if ts_def:
|
||||
parent_tool_names.update(ts_def.get("tools", []))
|
||||
|
||||
if not parent_tool_names:
|
||||
return set(parent_toolsets)
|
||||
|
||||
expanded = set(parent_toolsets)
|
||||
for ts_name, ts_def in TOOLSETS.items():
|
||||
if ts_name in expanded:
|
||||
continue
|
||||
ts_tools = ts_def.get("tools", [])
|
||||
if ts_tools and set(ts_tools).issubset(parent_tool_names):
|
||||
expanded.add(ts_name)
|
||||
return expanded
|
||||
|
||||
|
||||
def _preserve_parent_mcp_toolsets(
|
||||
child_toolsets: List[str], parent_toolsets: set[str]
|
||||
) -> List[str]:
|
||||
@@ -907,8 +938,11 @@ def _build_child_agent(
|
||||
parent_toolsets = set(DEFAULT_TOOLSETS)
|
||||
|
||||
if toolsets:
|
||||
# Intersect with parent — subagent must not gain tools the parent lacks
|
||||
child_toolsets = [t for t in toolsets if t in parent_toolsets]
|
||||
# Intersect with parent — subagent must not gain tools the parent lacks.
|
||||
# Expand composite toolsets (e.g. hermes-cli) so that individual
|
||||
# toolset names (e.g. web, terminal) are recognised during intersection.
|
||||
expanded_parent = _expand_parent_toolsets(parent_toolsets)
|
||||
child_toolsets = [t for t in toolsets if t in expanded_parent]
|
||||
if _get_inherit_mcp_toolsets():
|
||||
child_toolsets = _preserve_parent_mcp_toolsets(
|
||||
child_toolsets, parent_toolsets
|
||||
@@ -2479,7 +2513,7 @@ DELEGATE_TASK_SCHEMA = {
|
||||
},
|
||||
"acp_command": {
|
||||
"type": "string",
|
||||
"description": "Per-task ACP command override (e.g. 'claude'). Overrides the top-level acp_command for this task only.",
|
||||
"description": "Per-task ACP command override (e.g. 'copilot'). Overrides the top-level acp_command for this task only.",
|
||||
},
|
||||
"acp_args": {
|
||||
"type": "array",
|
||||
@@ -2519,10 +2553,11 @@ DELEGATE_TASK_SCHEMA = {
|
||||
"acp_command": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Override ACP command for child agents (e.g. 'claude', 'copilot'). "
|
||||
"Override ACP command for child agents (e.g. 'copilot'). "
|
||||
"When set, children use ACP subprocess transport instead of inheriting "
|
||||
"the parent's transport. Enables spawning Claude Code (claude --acp --stdio) "
|
||||
"or other ACP-capable agents from any parent, including Discord/Telegram/CLI."
|
||||
"the parent's transport. Requires an ACP-compatible CLI "
|
||||
"(currently GitHub Copilot CLI via 'copilot --acp --stdio'). "
|
||||
"See agent/copilot_acp_client.py for the implementation."
|
||||
),
|
||||
},
|
||||
"acp_args": {
|
||||
@@ -2530,7 +2565,7 @@ DELEGATE_TASK_SCHEMA = {
|
||||
"items": {"type": "string"},
|
||||
"description": (
|
||||
"Arguments for the ACP command (default: ['--acp', '--stdio']). "
|
||||
"Only used when acp_command is set. Example: ['--acp', '--stdio', '--model', 'claude-opus-4-6']"
|
||||
"Only used when acp_command is set."
|
||||
),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -418,6 +418,12 @@ def _unpin_message(token: str, channel_id: str, message_id: str, **_kwargs: Any)
|
||||
return json.dumps({"success": True, "message": f"Message {message_id} unpinned."})
|
||||
|
||||
|
||||
def _delete_message(token: str, channel_id: str, message_id: str, **_kwargs: Any) -> str:
|
||||
"""Delete a message from a channel or thread."""
|
||||
_discord_request("DELETE", f"/channels/{channel_id}/messages/{message_id}", token)
|
||||
return json.dumps({"success": True, "message": f"Message {message_id} deleted."})
|
||||
|
||||
|
||||
def _create_thread(
|
||||
token: str, channel_id: str, name: str,
|
||||
message_id: Optional[str] = None,
|
||||
@@ -476,6 +482,7 @@ _ACTIONS = {
|
||||
"list_pins": _list_pins,
|
||||
"pin_message": _pin_message,
|
||||
"unpin_message": _unpin_message,
|
||||
"delete_message": _delete_message,
|
||||
"create_thread": _create_thread,
|
||||
"add_role": _add_role,
|
||||
"remove_role": _remove_role,
|
||||
@@ -502,6 +509,7 @@ _ACTION_MANIFEST: List[Tuple[str, str, str]] = [
|
||||
("list_pins", "(channel_id)", "pinned messages in a channel"),
|
||||
("pin_message", "(channel_id, message_id)", "pin a message"),
|
||||
("unpin_message", "(channel_id, message_id)", "unpin a message"),
|
||||
("delete_message", "(channel_id, message_id)", "delete a message"),
|
||||
("create_thread", "(channel_id, name)", "create a public thread; optional message_id anchor"),
|
||||
("add_role", "(guild_id, user_id, role_id)", "assign a role"),
|
||||
("remove_role", "(guild_id, user_id, role_id)", "remove a role"),
|
||||
@@ -522,6 +530,7 @@ _REQUIRED_PARAMS: Dict[str, List[str]] = {
|
||||
"list_pins": ["channel_id"],
|
||||
"pin_message": ["channel_id", "message_id"],
|
||||
"unpin_message": ["channel_id", "message_id"],
|
||||
"delete_message": ["channel_id", "message_id"],
|
||||
"create_thread": ["channel_id", "name"],
|
||||
"add_role": ["guild_id", "user_id", "role_id"],
|
||||
"remove_role": ["guild_id", "user_id", "role_id"],
|
||||
@@ -758,6 +767,9 @@ _ACTION_403_HINT = {
|
||||
"unpin_message": (
|
||||
"Bot lacks MANAGE_MESSAGES permission in this channel."
|
||||
),
|
||||
"delete_message": (
|
||||
"Bot lacks MANAGE_MESSAGES permission in this channel, or cannot view the channel/message."
|
||||
),
|
||||
"create_thread": (
|
||||
"Bot lacks CREATE_PUBLIC_THREADS in this channel, or cannot view it."
|
||||
),
|
||||
|
||||
@@ -489,6 +489,26 @@ class BaseEnvironment(ABC):
|
||||
|
||||
def _drain():
|
||||
fd = proc.stdout.fileno()
|
||||
# select.select does NOT work on pipe fds on Windows (only sockets).
|
||||
# Use blocking os.read in a daemon thread instead — safe because
|
||||
# EOF arrives promptly when bash exits.
|
||||
if os.name == "nt":
|
||||
try:
|
||||
while True:
|
||||
chunk = os.read(fd, 4096)
|
||||
if not chunk:
|
||||
break
|
||||
output_chunks.append(decoder.decode(chunk))
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
tail = decoder.decode(b"", final=True)
|
||||
if tail:
|
||||
output_chunks.append(tail)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
idle_after_exit = 0
|
||||
try:
|
||||
while True:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
@@ -403,6 +404,12 @@ class LocalEnvironment(BaseEnvironment):
|
||||
)
|
||||
self.cwd = safe_cwd
|
||||
|
||||
# On Windows, self.cwd may be a Git Bash-style path (/c/Users/...)
|
||||
# from pwd output. subprocess.Popen needs a native Windows path.
|
||||
_popen_cwd = self.cwd
|
||||
if _IS_WINDOWS and _popen_cwd and re.match(r'^/[a-zA-Z]/', _popen_cwd):
|
||||
_popen_cwd = _popen_cwd[1].upper() + ':' + _popen_cwd[2:].replace('/', '\\')
|
||||
|
||||
proc = subprocess.Popen(
|
||||
args,
|
||||
text=True,
|
||||
@@ -413,7 +420,7 @@ class LocalEnvironment(BaseEnvironment):
|
||||
stderr=subprocess.STDOUT,
|
||||
stdin=subprocess.PIPE if stdin_data is not None else subprocess.DEVNULL,
|
||||
preexec_fn=None if _IS_WINDOWS else os.setsid,
|
||||
cwd=self.cwd,
|
||||
cwd=_popen_cwd,
|
||||
)
|
||||
if not _IS_WINDOWS:
|
||||
try:
|
||||
|
||||
@@ -879,6 +879,21 @@ IMAGE_GENERATE_SCHEMA = {
|
||||
}
|
||||
|
||||
|
||||
def _read_configured_image_model():
|
||||
"""Return the value of ``image_gen.model`` from config.yaml, or None."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
section = cfg.get("image_gen") if isinstance(cfg, dict) else None
|
||||
if isinstance(section, dict):
|
||||
value = section.get("model")
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
except Exception as exc:
|
||||
logger.debug("Could not read image_gen.model: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _read_configured_image_provider():
|
||||
"""Return the value of ``image_gen.provider`` from config.yaml, or None.
|
||||
|
||||
@@ -915,6 +930,9 @@ def _dispatch_to_plugin_provider(prompt: str, aspect_ratio: str):
|
||||
if not configured or configured == "fal":
|
||||
return None
|
||||
|
||||
# Also read configured model so we can pass it to the plugin
|
||||
configured_model = _read_configured_image_model()
|
||||
|
||||
try:
|
||||
# Import locally so plugin discovery isn't triggered just by
|
||||
# importing this module (tests rely on that).
|
||||
@@ -950,7 +968,10 @@ def _dispatch_to_plugin_provider(prompt: str, aspect_ratio: str):
|
||||
})
|
||||
|
||||
try:
|
||||
result = provider.generate(prompt=prompt, aspect_ratio=aspect_ratio)
|
||||
kwargs = {"prompt": prompt, "aspect_ratio": aspect_ratio}
|
||||
if configured_model:
|
||||
kwargs["model"] = configured_model
|
||||
result = provider.generate(**kwargs)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Image gen provider '%s' raised: %s",
|
||||
|
||||
+17
-1
@@ -315,7 +315,15 @@ def _handle_block(args: dict, **kw) -> str:
|
||||
|
||||
|
||||
def _handle_heartbeat(args: dict, **kw) -> str:
|
||||
"""Signal that the worker is still alive during a long operation."""
|
||||
"""Signal that the worker is still alive during a long operation.
|
||||
|
||||
Extends the claim TTL via ``heartbeat_claim`` AND records a heartbeat
|
||||
event via ``heartbeat_worker``. Without the ``heartbeat_claim`` half,
|
||||
a diligent worker that loops this tool while a single tool call
|
||||
blocks the agent for >DEFAULT_CLAIM_TTL_SECONDS still gets reclaimed
|
||||
by ``release_stale_claims`` — which is exactly the trap that
|
||||
``heartbeat_claim``'s docstring warns against.
|
||||
"""
|
||||
tid = _default_task_id(args.get("task_id"))
|
||||
if not tid:
|
||||
return tool_error(
|
||||
@@ -328,6 +336,14 @@ def _handle_heartbeat(args: dict, **kw) -> str:
|
||||
try:
|
||||
kb, conn = _connect()
|
||||
try:
|
||||
# Extend the claim TTL first. The dispatcher pins
|
||||
# HERMES_KANBAN_CLAIM_LOCK in the worker env at spawn time
|
||||
# (see _default_spawn in kanban_db.py); falling back to the
|
||||
# default _claimer_id() covers locally-driven workers that
|
||||
# never went through the dispatcher path.
|
||||
claim_lock = os.environ.get("HERMES_KANBAN_CLAIM_LOCK")
|
||||
kb.heartbeat_claim(conn, tid, claimer=claim_lock)
|
||||
|
||||
ok = kb.heartbeat_worker(
|
||||
conn,
|
||||
tid,
|
||||
|
||||
+62
-7
@@ -37,7 +37,9 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import socket
|
||||
import stat
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
@@ -59,6 +61,7 @@ try:
|
||||
from mcp.shared.auth import (
|
||||
OAuthClientInformationFull,
|
||||
OAuthClientMetadata,
|
||||
OAuthMetadata,
|
||||
OAuthToken,
|
||||
)
|
||||
|
||||
@@ -160,15 +163,41 @@ def _read_json(path: Path) -> dict | None:
|
||||
|
||||
|
||||
def _write_json(path: Path, data: dict) -> None:
|
||||
"""Write a dict as JSON with restricted permissions (0o600)."""
|
||||
"""Write a dict as JSON with restricted permissions (0o600).
|
||||
|
||||
Uses ``os.open`` with ``O_EXCL`` and an explicit mode so the file is
|
||||
created atomically at 0o600. The previous ``write_text`` + post-write
|
||||
``chmod`` opened a TOCTOU window where the temp file briefly inherited
|
||||
the process umask (commonly 0o644 = world-readable), exposing OAuth
|
||||
tokens to other local users between create and chmod. Mirrors the fix
|
||||
in ``agent/google_oauth.py`` (#19673).
|
||||
"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(".tmp")
|
||||
# Tighten parent dir to 0o700 so siblings can't traverse to the creds.
|
||||
# No-op on Windows (POSIX mode bits aren't enforced); ignore failures.
|
||||
try:
|
||||
tmp.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8")
|
||||
os.chmod(tmp, 0o600)
|
||||
tmp.rename(path)
|
||||
os.chmod(path.parent, 0o700)
|
||||
except OSError:
|
||||
tmp.unlink(missing_ok=True)
|
||||
pass
|
||||
# Per-process random suffix avoids collisions between concurrent
|
||||
# writers and stale leftovers from a prior crashed write.
|
||||
tmp = path.with_suffix(f".tmp.{os.getpid()}.{secrets.token_hex(4)}")
|
||||
try:
|
||||
fd = os.open(
|
||||
str(tmp),
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
||||
stat.S_IRUSR | stat.S_IWUSR,
|
||||
)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, indent=2, default=str)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.replace(tmp, path)
|
||||
except OSError:
|
||||
try:
|
||||
tmp.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
@@ -184,6 +213,7 @@ class HermesTokenStorage:
|
||||
|
||||
HERMES_HOME/mcp-tokens/<server_name>.json -- tokens
|
||||
HERMES_HOME/mcp-tokens/<server_name>.client.json -- client info
|
||||
HERMES_HOME/mcp-tokens/<server_name>.meta.json -- oauth server metadata
|
||||
"""
|
||||
|
||||
def __init__(self, server_name: str):
|
||||
@@ -195,6 +225,9 @@ class HermesTokenStorage:
|
||||
def _client_info_path(self) -> Path:
|
||||
return _get_token_dir() / f"{self._server_name}.client.json"
|
||||
|
||||
def _meta_path(self) -> Path:
|
||||
return _get_token_dir() / f"{self._server_name}.meta.json"
|
||||
|
||||
# -- tokens ------------------------------------------------------------
|
||||
|
||||
async def get_tokens(self) -> "OAuthToken | None":
|
||||
@@ -272,11 +305,33 @@ class HermesTokenStorage:
|
||||
_write_json(self._client_info_path(), client_info.model_dump(mode="json", exclude_none=True))
|
||||
logger.debug("OAuth client info saved for %s", self._server_name)
|
||||
|
||||
# -- oauth server metadata --------------------------------------------
|
||||
# The MCP SDK keeps discovered ``OAuthMetadata`` (token endpoint URL,
|
||||
# etc.) in memory only. Persisting it here lets a restarted process
|
||||
# refresh tokens without re-running metadata discovery. Without this,
|
||||
# cold-start refresh requests fall back to the SDK's guessed
|
||||
# ``{server_url}/token`` which returns 404 on most real providers and
|
||||
# forces a full browser re-authorization.
|
||||
|
||||
def save_oauth_metadata(self, metadata: "OAuthMetadata") -> None:
|
||||
_write_json(self._meta_path(), metadata.model_dump(exclude_none=True, mode="json"))
|
||||
logger.debug("OAuth metadata saved for %s", self._server_name)
|
||||
|
||||
def load_oauth_metadata(self) -> "OAuthMetadata | None":
|
||||
data = _read_json(self._meta_path())
|
||||
if data is None:
|
||||
return None
|
||||
try:
|
||||
return OAuthMetadata.model_validate(data)
|
||||
except (ValueError, TypeError, KeyError) as exc:
|
||||
logger.warning("Corrupt OAuth metadata at %s -- ignoring: %s", self._meta_path(), exc)
|
||||
return None
|
||||
|
||||
# -- cleanup -----------------------------------------------------------
|
||||
|
||||
def remove(self) -> None:
|
||||
"""Delete all stored OAuth state for this server."""
|
||||
for p in (self._tokens_path(), self._client_info_path()):
|
||||
for p in (self._tokens_path(), self._client_info_path(), self._meta_path()):
|
||||
p.unlink(missing_ok=True)
|
||||
|
||||
def has_cached_tokens(self) -> bool:
|
||||
|
||||
@@ -148,6 +148,27 @@ def _make_hermes_provider_class() -> Optional[type]:
|
||||
if tokens is not None and tokens.expires_in is not None:
|
||||
self.context.update_token_expiry(tokens)
|
||||
|
||||
# Cold-load: restore OAuth server metadata from disk before any
|
||||
# refresh attempt. Without this, a restarted process with cached
|
||||
# tokens but no in-memory metadata would fall back to the SDK's
|
||||
# guessed ``{server_url}/token`` path (returns 404 on most real
|
||||
# providers) and require a full browser re-authorization.
|
||||
storage = self.context.storage
|
||||
from tools.mcp_oauth import HermesTokenStorage
|
||||
if (
|
||||
isinstance(storage, HermesTokenStorage)
|
||||
and self.context.oauth_metadata is None
|
||||
):
|
||||
meta = storage.load_oauth_metadata()
|
||||
if meta is not None:
|
||||
self.context.oauth_metadata = meta
|
||||
logger.debug(
|
||||
"MCP OAuth '%s': restored metadata from disk "
|
||||
"(token_endpoint=%s)",
|
||||
self._hermes_server_name,
|
||||
meta.token_endpoint,
|
||||
)
|
||||
|
||||
# Pre-flight OAuth AS discovery so ``_refresh_token`` has a
|
||||
# correct ``token_endpoint`` before the first refresh attempt.
|
||||
# Only runs when we have tokens on cold-load but no cached
|
||||
@@ -229,6 +250,12 @@ def _make_hermes_provider_class() -> Optional[type]:
|
||||
break
|
||||
if asm:
|
||||
self.context.oauth_metadata = asm
|
||||
# Persist immediately so a subsequent cold-load can
|
||||
# skip discovery entirely.
|
||||
storage = self.context.storage
|
||||
from tools.mcp_oauth import HermesTokenStorage
|
||||
if isinstance(storage, HermesTokenStorage):
|
||||
storage.save_oauth_metadata(asm)
|
||||
logger.debug(
|
||||
"MCP OAuth '%s': pre-flight ASM discovered "
|
||||
"token_endpoint=%s",
|
||||
@@ -236,6 +263,27 @@ def _make_hermes_provider_class() -> Optional[type]:
|
||||
)
|
||||
break
|
||||
|
||||
def _persist_oauth_metadata_if_changed(self) -> None:
|
||||
"""Persist discovered OAuth metadata for future process restarts.
|
||||
|
||||
Called after the SDK's normal 401-branch auth flow completes so
|
||||
metadata discovered via the lazy path (not pre-flight) is also
|
||||
saved. No-op when nothing to persist or metadata hasn't changed.
|
||||
"""
|
||||
meta = self.context.oauth_metadata
|
||||
if meta is None:
|
||||
return
|
||||
storage = self.context.storage
|
||||
from tools.mcp_oauth import HermesTokenStorage
|
||||
if not isinstance(storage, HermesTokenStorage):
|
||||
return
|
||||
existing = storage.load_oauth_metadata()
|
||||
if (
|
||||
existing is None
|
||||
or str(existing.token_endpoint) != str(meta.token_endpoint)
|
||||
):
|
||||
storage.save_oauth_metadata(meta)
|
||||
|
||||
async def async_auth_flow(self, request): # type: ignore[override]
|
||||
# Pre-flow hook: ask the manager to refresh from disk if needed.
|
||||
# Any failure here is non-fatal — we just log and proceed with
|
||||
@@ -271,6 +319,9 @@ def _make_hermes_provider_class() -> Optional[type]:
|
||||
incoming = yield outgoing
|
||||
outgoing = await inner.asend(incoming)
|
||||
except StopAsyncIteration:
|
||||
# Persist any metadata the SDK discovered lazily during the
|
||||
# 401 branch so a subsequent cold-load skips discovery.
|
||||
self._persist_oauth_metadata_if_changed()
|
||||
return
|
||||
|
||||
return HermesMCPOAuthProvider
|
||||
|
||||
+256
-28
@@ -2,9 +2,9 @@
|
||||
"""
|
||||
MCP (Model Context Protocol) Client Support
|
||||
|
||||
Connects to external MCP servers via stdio or HTTP/StreamableHTTP transport,
|
||||
discovers their tools, and registers them into the hermes-agent tool registry
|
||||
so the agent can call them like any built-in tool.
|
||||
Connects to external MCP servers via stdio, HTTP/StreamableHTTP, or SSE
|
||||
transport, discovers their tools, and registers them into the hermes-agent
|
||||
tool registry so the agent can call them like any built-in tool.
|
||||
|
||||
Configuration is read from ~/.hermes/config.yaml under the ``mcp_servers`` key.
|
||||
The ``mcp`` Python package is optional -- if not installed, this module is a
|
||||
@@ -29,7 +29,11 @@ Example config::
|
||||
headers:
|
||||
Authorization: "Bearer sk-..."
|
||||
timeout: 180
|
||||
analysis:
|
||||
searxng:
|
||||
url: "http://localhost:8000/sse"
|
||||
transport: sse # use SSE transport instead of Streamable HTTP
|
||||
timeout: 180
|
||||
connect_timeout: 10
|
||||
command: "npx"
|
||||
args: ["-y", "analysis-server"]
|
||||
sampling: # server-initiated LLM requests
|
||||
@@ -44,6 +48,7 @@ Example config::
|
||||
|
||||
Features:
|
||||
- Stdio transport (command + args) and HTTP/StreamableHTTP transport (url)
|
||||
- SSE transport (transport: sse) for MCP servers using the SSE protocol
|
||||
- Automatic reconnection with exponential backoff (up to 5 retries)
|
||||
- Environment variable filtering for stdio subprocesses (security)
|
||||
- Credential stripping in error messages returned to the LLM
|
||||
@@ -191,6 +196,12 @@ try:
|
||||
from mcp.types import LATEST_PROTOCOL_VERSION
|
||||
except ImportError:
|
||||
logger.debug("mcp.types.LATEST_PROTOCOL_VERSION not available -- using fallback protocol version")
|
||||
# SSE transport client (for MCP servers using SSE transport instead of Streamable HTTP)
|
||||
try:
|
||||
from mcp.client.sse import sse_client
|
||||
except ImportError:
|
||||
sse_client = None
|
||||
logger.debug("mcp.client.sse.sse_client not available -- SSE transport disabled")
|
||||
# Sampling types -- separated so older SDK versions don't break MCP support
|
||||
try:
|
||||
from mcp.types import (
|
||||
@@ -301,6 +312,18 @@ def _sanitize_error(text: str) -> str:
|
||||
return _CREDENTIAL_PATTERN.sub("[REDACTED]", text)
|
||||
|
||||
|
||||
def _exc_str(exc: BaseException) -> str:
|
||||
"""Return a non-empty human-readable string for *exc*.
|
||||
|
||||
Some exception classes (e.g. ``anyio.ClosedResourceError``) are raised
|
||||
without a message argument, so ``str(exc)`` is ``""``. This helper
|
||||
falls back to ``repr(exc)`` so that error messages shown to the user
|
||||
and logged to disk always carry *some* diagnostic information.
|
||||
"""
|
||||
text = str(exc).strip()
|
||||
return text if text else repr(exc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP tool description content scanning
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -403,6 +426,64 @@ def _resolve_stdio_command(command: str, env: dict) -> tuple[str, dict]:
|
||||
return resolved_command, resolved_env
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP ImageContent block → Hermes MEDIA tag
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mcp_image_extension_for_mime_type(mime_type: str) -> str:
|
||||
"""Return a reasonable file extension for an MCP image MIME type."""
|
||||
import mimetypes
|
||||
normalized = (mime_type or "").split(";", 1)[0].strip().lower()
|
||||
if normalized in {"image/jpeg", "image/jpg"}:
|
||||
return ".jpg"
|
||||
return mimetypes.guess_extension(normalized) or ".png"
|
||||
|
||||
|
||||
def _cache_mcp_image_block(block) -> str:
|
||||
"""Cache an MCP ``ImageContent`` block to the shared image cache and
|
||||
return a ``MEDIA:<path>`` tag that Hermes gateways know how to render.
|
||||
|
||||
Returns an empty string when *block* is not an image, when the base64
|
||||
payload is malformed, or when the cache helper rejects the bytes (e.g.
|
||||
non-image MIME masquerading as an image). Errors are logged, not raised:
|
||||
a single bad block shouldn't kill the tool result, and the caller will
|
||||
fall through to any text blocks that did parse.
|
||||
"""
|
||||
import base64
|
||||
|
||||
data = getattr(block, "data", None)
|
||||
mime_type = getattr(block, "mimeType", None)
|
||||
normalized_mime = str(mime_type or "").split(";", 1)[0].strip().lower()
|
||||
if data is None or not normalized_mime.startswith("image/"):
|
||||
return ""
|
||||
|
||||
try:
|
||||
raw_bytes = base64.b64decode(data)
|
||||
except (TypeError, ValueError) as exc:
|
||||
logger.warning("MCP image block decode failed (%s): %s", normalized_mime, exc)
|
||||
return ""
|
||||
|
||||
try:
|
||||
from gateway.platforms.base import cache_image_from_bytes
|
||||
|
||||
image_path = cache_image_from_bytes(
|
||||
raw_bytes,
|
||||
ext=_mcp_image_extension_for_mime_type(normalized_mime),
|
||||
)
|
||||
except ImportError:
|
||||
# gateway.platforms.base not importable in this process (e.g. cron
|
||||
# without gateway deps). Fall back to silently dropping — callers
|
||||
# get any text blocks that did parse.
|
||||
logger.debug("MCP image caching skipped — gateway.platforms.base unavailable")
|
||||
return ""
|
||||
except Exception as exc:
|
||||
logger.warning("MCP image block cache failed: %s", exc)
|
||||
return ""
|
||||
|
||||
return f"MEDIA:{image_path}"
|
||||
|
||||
|
||||
def _format_connect_error(exc: BaseException) -> str:
|
||||
"""Render nested MCP connection errors into an actionable short message."""
|
||||
|
||||
@@ -820,7 +901,7 @@ class SamplingHandler:
|
||||
except Exception as exc:
|
||||
self.metrics["errors"] += 1
|
||||
return self._error(
|
||||
f"Sampling LLM call failed: {_sanitize_error(str(exc))}"
|
||||
f"Sampling LLM call failed: {_sanitize_error(_exc_str(exc))}"
|
||||
)
|
||||
|
||||
# Guard against empty choices (content filtering, provider errors)
|
||||
@@ -869,6 +950,7 @@ class MCPServerTask:
|
||||
"_tools", "_error", "_config",
|
||||
"_sampling", "_registered_tool_names", "_auth_type", "_refresh_lock",
|
||||
"_rpc_lock", "_pending_refresh_tasks",
|
||||
"initialize_result",
|
||||
)
|
||||
|
||||
def __init__(self, name: str):
|
||||
@@ -899,6 +981,12 @@ class MCPServerTask:
|
||||
# transports for conservative per-server ordering.
|
||||
self._rpc_lock = asyncio.Lock()
|
||||
self._pending_refresh_tasks: set[asyncio.Task] = set()
|
||||
# Captures the ``InitializeResult`` returned by
|
||||
# ``await session.initialize()`` so downstream code can inspect the
|
||||
# server's real advertised capabilities (``.capabilities.resources``,
|
||||
# ``.capabilities.prompts``) instead of assuming every ``ClientSession``
|
||||
# method attribute corresponds to a supported server method. See #18051.
|
||||
self.initialize_result: Optional[Any] = None
|
||||
|
||||
def _is_http(self) -> bool:
|
||||
"""Check if this server uses HTTP transport."""
|
||||
@@ -1144,7 +1232,7 @@ class MCPServerTask:
|
||||
async with ClientSession(
|
||||
read_stream, write_stream, **sampling_kwargs
|
||||
) as session:
|
||||
await session.initialize()
|
||||
self.initialize_result = await session.initialize()
|
||||
self.session = session
|
||||
await self._discover_tools()
|
||||
self._ready.set()
|
||||
@@ -1210,6 +1298,51 @@ class MCPServerTask:
|
||||
if _MCP_NOTIFICATION_TYPES and _MCP_MESSAGE_HANDLER_SUPPORTED:
|
||||
sampling_kwargs["message_handler"] = self._make_message_handler()
|
||||
|
||||
# SSE transport (for MCP servers that implement the SSE transport protocol
|
||||
# rather than Streamable HTTP). Configure with ``transport: sse`` in the
|
||||
# mcp_servers entry in config.yaml.
|
||||
if config.get("transport") == "sse":
|
||||
if sse_client is None:
|
||||
raise ImportError(
|
||||
f"MCP server '{self.name}' requires SSE transport but "
|
||||
"mcp.client.sse.sse_client is not available. "
|
||||
"Upgrade the mcp package to get SSE support."
|
||||
)
|
||||
# sse_read_timeout governs how long sse_client will wait between
|
||||
# events on the SSE stream. Using the tool_timeout (default 60s)
|
||||
# here is wrong: SSE servers commonly hold the stream idle for
|
||||
# minutes between events, so a 60s read timeout drops the
|
||||
# connection after the first slow stretch. 300s matches the
|
||||
# Streamable HTTP code path's httpx read timeout below. Original
|
||||
# observation from @amiller in PR #5981 (Router Teamwork,
|
||||
# Supermemory on Cloudflare Workers idle-disconnect at ~60s).
|
||||
_sse_kwargs: dict = {
|
||||
"url": url,
|
||||
"headers": headers or None,
|
||||
"timeout": float(connect_timeout),
|
||||
"sse_read_timeout": 300.0,
|
||||
}
|
||||
if _oauth_auth is not None:
|
||||
# Pass OAuth auth through to sse_client so SSE MCP servers
|
||||
# behind OAuth 2.1 PKCE work. Previously built but never
|
||||
# forwarded — SSE OAuth would silently fail with 401s.
|
||||
_sse_kwargs["auth"] = _oauth_auth
|
||||
async with sse_client(**_sse_kwargs) as (read_stream, write_stream):
|
||||
async with ClientSession(
|
||||
read_stream, write_stream, **sampling_kwargs
|
||||
) as session:
|
||||
self.initialize_result = await session.initialize()
|
||||
self.session = session
|
||||
await self._discover_tools()
|
||||
self._ready.set()
|
||||
reason = await self._wait_for_lifecycle_event()
|
||||
if reason == "reconnect":
|
||||
logger.info(
|
||||
"MCP server '%s': reconnect requested — "
|
||||
"tearing down SSE session", self.name,
|
||||
)
|
||||
return
|
||||
|
||||
if _MCP_NEW_HTTP:
|
||||
# New API (mcp >= 1.24.0): build an explicit httpx.AsyncClient
|
||||
# matching the SDK's own create_mcp_http_client defaults.
|
||||
@@ -1245,7 +1378,7 @@ class MCPServerTask:
|
||||
read_stream, write_stream, _get_session_id,
|
||||
):
|
||||
async with ClientSession(read_stream, write_stream, **sampling_kwargs) as session:
|
||||
await session.initialize()
|
||||
self.initialize_result = await session.initialize()
|
||||
self.session = session
|
||||
await self._discover_tools()
|
||||
self._ready.set()
|
||||
@@ -1268,7 +1401,7 @@ class MCPServerTask:
|
||||
read_stream, write_stream, _get_session_id,
|
||||
):
|
||||
async with ClientSession(read_stream, write_stream, **sampling_kwargs) as session:
|
||||
await session.initialize()
|
||||
self.initialize_result = await session.initialize()
|
||||
self.session = session
|
||||
await self._discover_tools()
|
||||
self._ready.set()
|
||||
@@ -1345,6 +1478,18 @@ class MCPServerTask:
|
||||
# still detect a transient in-flight state — it'll be
|
||||
# re-set after the fresh session initializes.
|
||||
continue
|
||||
except asyncio.CancelledError:
|
||||
# Task was cancelled (shutdown, gateway restart, explicit
|
||||
# task.cancel()). Don't treat this as a connection failure —
|
||||
# CancelledError inherits from BaseException (not Exception)
|
||||
# in Python 3.11+, so the broad ``except Exception`` below
|
||||
# would NOT catch it; we'd silently exit the reconnect loop
|
||||
# and the MCP server would stay dead until Hermes is fully
|
||||
# restarted. Re-raise so the task's cancellation propagates
|
||||
# correctly to asyncio's task machinery and ``shutdown()``'s
|
||||
# ``await self._task`` completes. See #9930.
|
||||
self.session = None
|
||||
raise
|
||||
except Exception as exc:
|
||||
self.session = None
|
||||
|
||||
@@ -1697,6 +1842,12 @@ _SESSION_EXPIRED_MARKERS: tuple = (
|
||||
"session not found",
|
||||
"unknown session",
|
||||
"session terminated",
|
||||
"closedresourceerror",
|
||||
"closed resource",
|
||||
"transport is closed",
|
||||
"connection closed",
|
||||
"broken pipe",
|
||||
"end of file",
|
||||
)
|
||||
|
||||
|
||||
@@ -1900,7 +2051,8 @@ def _run_on_mcp_loop(coro, timeout: float = 30):
|
||||
if loop is None or not loop.is_running():
|
||||
raise RuntimeError("MCP event loop is not running")
|
||||
future = asyncio.run_coroutine_threadsafe(coro, loop)
|
||||
deadline = None if timeout is None else time.monotonic() + timeout
|
||||
start_time = time.monotonic()
|
||||
deadline = None if timeout is None else start_time + timeout
|
||||
|
||||
while True:
|
||||
if is_interrupted():
|
||||
@@ -1911,7 +2063,12 @@ def _run_on_mcp_loop(coro, timeout: float = 30):
|
||||
if deadline is not None:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return future.result(timeout=0)
|
||||
future.cancel()
|
||||
elapsed = time.monotonic() - start_time
|
||||
raise TimeoutError(
|
||||
f"MCP call timed out after {elapsed:.1f}s "
|
||||
f"(configured timeout: {float(timeout):.1f}s)"
|
||||
)
|
||||
wait_timeout = min(wait_timeout, remaining)
|
||||
|
||||
try:
|
||||
@@ -2054,11 +2211,25 @@ def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float):
|
||||
)
|
||||
}, ensure_ascii=False)
|
||||
|
||||
# Collect text from content blocks
|
||||
# Collect text from content blocks. MCP tool results can also
|
||||
# include ImageContent blocks (screenshot / Blockbench / Playwright
|
||||
# etc.); cache those via the gateway's image-cache helper so they
|
||||
# flow through Hermes' MEDIA: tag convention and out to messaging
|
||||
# adapters that render images natively. Without this, image blocks
|
||||
# were silently dropped and the agent got an empty response.
|
||||
#
|
||||
# Distilled from #17915 (c3115644151) and #10848 (gnanirahulnutakki),
|
||||
# both too stale to cherry-pick. #10848's approach (integrate with
|
||||
# Hermes' MEDIA tag + cache_image_from_bytes) was the cleaner of
|
||||
# the two — plugs into existing infrastructure.
|
||||
parts: List[str] = []
|
||||
for block in (result.content or []):
|
||||
if hasattr(block, "text"):
|
||||
if hasattr(block, "text") and block.text:
|
||||
parts.append(block.text)
|
||||
continue
|
||||
image_tag = _cache_mcp_image_block(block)
|
||||
if image_tag:
|
||||
parts.append(image_tag)
|
||||
text_result = "\n".join(parts) if parts else ""
|
||||
|
||||
# Combine content + structuredContent when both are present.
|
||||
@@ -2120,7 +2291,7 @@ def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float):
|
||||
)
|
||||
return json.dumps({
|
||||
"error": _sanitize_error(
|
||||
f"MCP call failed: {type(exc).__name__}: {exc}"
|
||||
f"MCP call failed: {type(exc).__name__}: {_exc_str(exc)}"
|
||||
)
|
||||
}, ensure_ascii=False)
|
||||
|
||||
@@ -2178,7 +2349,7 @@ def _make_list_resources_handler(server_name: str, tool_timeout: float):
|
||||
)
|
||||
return json.dumps({
|
||||
"error": _sanitize_error(
|
||||
f"MCP call failed: {type(exc).__name__}: {exc}"
|
||||
f"MCP call failed: {type(exc).__name__}: {_exc_str(exc)}"
|
||||
)
|
||||
}, ensure_ascii=False)
|
||||
|
||||
@@ -2238,7 +2409,7 @@ def _make_read_resource_handler(server_name: str, tool_timeout: float):
|
||||
)
|
||||
return json.dumps({
|
||||
"error": _sanitize_error(
|
||||
f"MCP call failed: {type(exc).__name__}: {exc}"
|
||||
f"MCP call failed: {type(exc).__name__}: {_exc_str(exc)}"
|
||||
)
|
||||
}, ensure_ascii=False)
|
||||
|
||||
@@ -2301,7 +2472,7 @@ def _make_list_prompts_handler(server_name: str, tool_timeout: float):
|
||||
)
|
||||
return json.dumps({
|
||||
"error": _sanitize_error(
|
||||
f"MCP call failed: {type(exc).__name__}: {exc}"
|
||||
f"MCP call failed: {type(exc).__name__}: {_exc_str(exc)}"
|
||||
)
|
||||
}, ensure_ascii=False)
|
||||
|
||||
@@ -2372,7 +2543,7 @@ def _make_get_prompt_handler(server_name: str, tool_timeout: float):
|
||||
)
|
||||
return json.dumps({
|
||||
"error": _sanitize_error(
|
||||
f"MCP call failed: {type(exc).__name__}: {exc}"
|
||||
f"MCP call failed: {type(exc).__name__}: {_exc_str(exc)}"
|
||||
)
|
||||
}, ensure_ascii=False)
|
||||
|
||||
@@ -2642,6 +2813,23 @@ _UTILITY_CAPABILITY_METHODS = {
|
||||
"get_prompt": "get_prompt",
|
||||
}
|
||||
|
||||
# Maps each utility handler to the MCP capability key that must be non-None
|
||||
# on the server's ``initialize`` response for the handler to be registered.
|
||||
# Source of truth: MCP spec — capabilities.resources / capabilities.prompts
|
||||
# are present on the response only when the server actually implements
|
||||
# those request families. Without this gate, tools-only servers (e.g.
|
||||
# Context7 @upstash/context7-mcp, which advertises only ``tools``) had
|
||||
# all four utility stubs registered and every model call to them came
|
||||
# back with JSON-RPC ``-32601 Method not found``, which made the model
|
||||
# conclude the server was broken even when the real tools worked. See
|
||||
# #18051.
|
||||
_UTILITY_CAPABILITY_ATTRS = {
|
||||
"list_resources": "resources",
|
||||
"read_resource": "resources",
|
||||
"list_prompts": "prompts",
|
||||
"get_prompt": "prompts",
|
||||
}
|
||||
|
||||
|
||||
def _select_utility_schemas(server_name: str, server: MCPServerTask, config: dict) -> List[dict]:
|
||||
"""Select utility schemas based on config and server capabilities."""
|
||||
@@ -2649,6 +2837,16 @@ def _select_utility_schemas(server_name: str, server: MCPServerTask, config: dic
|
||||
resources_enabled = _parse_boolish(tools_filter.get("resources"), default=True)
|
||||
prompts_enabled = _parse_boolish(tools_filter.get("prompts"), default=True)
|
||||
|
||||
# ``initialize_result.capabilities`` is the source of truth: its sub-objects
|
||||
# (``resources``, ``prompts``) are non-None iff the server advertises that
|
||||
# request family. ``hasattr(server.session, ...)`` was the old gate but
|
||||
# ClientSession always has the four method attributes defined on the class,
|
||||
# so it never filtered anything.
|
||||
advertised_caps = None
|
||||
init_result = getattr(server, "initialize_result", None)
|
||||
if init_result is not None:
|
||||
advertised_caps = getattr(init_result, "capabilities", None)
|
||||
|
||||
selected: List[dict] = []
|
||||
for entry in _build_utility_schemas(server_name):
|
||||
handler_key = entry["handler_key"]
|
||||
@@ -2659,15 +2857,33 @@ def _select_utility_schemas(server_name: str, server: MCPServerTask, config: dic
|
||||
logger.debug("MCP server '%s': skipping utility '%s' (prompts disabled)", server_name, handler_key)
|
||||
continue
|
||||
|
||||
required_method = _UTILITY_CAPABILITY_METHODS[handler_key]
|
||||
if not hasattr(server.session, required_method):
|
||||
logger.debug(
|
||||
"MCP server '%s': skipping utility '%s' (session lacks %s)",
|
||||
server_name,
|
||||
handler_key,
|
||||
required_method,
|
||||
)
|
||||
continue
|
||||
# Preferred gate: check the server's advertised capabilities. Skip
|
||||
# if the capability is explicitly not advertised.
|
||||
if advertised_caps is not None:
|
||||
cap_attr = _UTILITY_CAPABILITY_ATTRS[handler_key]
|
||||
if getattr(advertised_caps, cap_attr, None) is None:
|
||||
logger.debug(
|
||||
"MCP server '%s': skipping utility '%s' "
|
||||
"(server does not advertise '%s' capability)",
|
||||
server_name,
|
||||
handler_key,
|
||||
cap_attr,
|
||||
)
|
||||
continue
|
||||
else:
|
||||
# Legacy fallback for test fixtures or older code paths where
|
||||
# initialize_result wasn't captured. Preserves the old behavior
|
||||
# of registering every stub in that case rather than regressing
|
||||
# any server that was working before this fix.
|
||||
required_method = _UTILITY_CAPABILITY_METHODS[handler_key]
|
||||
if not hasattr(server.session, required_method):
|
||||
logger.debug(
|
||||
"MCP server '%s': skipping utility '%s' (session lacks %s)",
|
||||
server_name,
|
||||
handler_key,
|
||||
required_method,
|
||||
)
|
||||
continue
|
||||
selected.append(entry)
|
||||
return selected
|
||||
|
||||
@@ -2880,7 +3096,19 @@ def register_mcp_servers(servers: Dict[str, dict]) -> List[str]:
|
||||
|
||||
# Per-server timeouts are handled inside _discover_and_register_server.
|
||||
# The outer timeout is generous: 120s total for parallel discovery.
|
||||
_run_on_mcp_loop(_discover_all(), timeout=120)
|
||||
#
|
||||
# Temporarily clear the interrupt flag on the current thread so that MCP
|
||||
# discovery is never cancelled by a stale interrupt from a prior agent
|
||||
# session (executor threads get reused and may carry old interrupt state).
|
||||
from tools.interrupt import is_interrupted as _is_interrupted, set_interrupt as _set_interrupt
|
||||
_was_interrupted = _is_interrupted()
|
||||
if _was_interrupted:
|
||||
_set_interrupt(False)
|
||||
try:
|
||||
_run_on_mcp_loop(_discover_all(), timeout=120)
|
||||
finally:
|
||||
if _was_interrupted:
|
||||
_set_interrupt(True)
|
||||
|
||||
# Log a summary so ACP callers get visibility into what was registered.
|
||||
with _lock:
|
||||
@@ -2965,7 +3193,7 @@ def get_mcp_status() -> List[dict]:
|
||||
active_servers = dict(_servers)
|
||||
|
||||
for name, cfg in configured.items():
|
||||
transport = "http" if "url" in cfg else "stdio"
|
||||
transport = cfg.get("transport", "http") if "url" in cfg else "stdio"
|
||||
server = active_servers.get(name)
|
||||
if server and server.session is not None:
|
||||
entry = {
|
||||
|
||||
@@ -84,6 +84,47 @@ def _sanitize_single_tool(tool: dict) -> dict:
|
||||
# argument coercion (``model_tools._schema_allows_null``) can still
|
||||
# map a model-emitted ``"null"`` string to Python ``None``.
|
||||
fn["parameters"] = strip_nullable_unions(fn["parameters"], keep_nullable_hint=True)
|
||||
# Strip top-level combinators that strict backends (OpenAI's Codex
|
||||
# endpoint at chatgpt.com/backend-api/codex) reject outright. Nested
|
||||
# combinators inside properties are preserved.
|
||||
fn["parameters"] = _strip_top_level_combinators(
|
||||
fn["parameters"], path=fn.get("name", "<tool>")
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
_TOP_LEVEL_FORBIDDEN_KEYS = ("allOf", "anyOf", "oneOf", "enum", "not")
|
||||
|
||||
|
||||
def _strip_top_level_combinators(params: dict, *, path: str = "<tool>") -> dict:
|
||||
"""Drop combinator keywords from the top-level of a function parameters schema.
|
||||
|
||||
OpenAI's Codex backend (``chatgpt.com/backend-api/codex``) is stricter
|
||||
than the public Functions API and rejects requests with::
|
||||
|
||||
Invalid schema for function 'X': schema must have type 'object' and
|
||||
not have 'oneOf'/'anyOf'/'allOf'/'enum'/'not' at the top level.
|
||||
|
||||
These keywords are typically used for conditional required-fields hints
|
||||
(``allOf: [{if: ..., then: {required: [...]}}]``). Removing them at the
|
||||
top level discards the hint but does not change which argument *values*
|
||||
are valid — the tool handler always re-validates required fields.
|
||||
|
||||
Only the *top* level is stripped; combinators nested inside a property's
|
||||
schema are preserved (the strict rule only applies to the outermost
|
||||
parameters object).
|
||||
"""
|
||||
if not isinstance(params, dict):
|
||||
return params
|
||||
out = dict(params)
|
||||
for key in _TOP_LEVEL_FORBIDDEN_KEYS:
|
||||
if key in out:
|
||||
logger.debug(
|
||||
"schema_sanitizer[%s]: stripped top-level %r combinator "
|
||||
"from tool parameters (strict-backend compat)",
|
||||
path, key,
|
||||
)
|
||||
out.pop(key, None)
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -242,6 +242,12 @@ def _handle_send(args):
|
||||
|
||||
from gateway.platforms.base import BasePlatformAdapter
|
||||
|
||||
# Capture [[as_document]] directive before extract_media strips it.
|
||||
# Image-extension files in this batch will route through send_document
|
||||
# instead of send_photo so the original bytes survive (e.g. info-graph
|
||||
# JPGs where Telegram's sendPhoto recompresses to 1280px).
|
||||
force_document_attachments = "[[as_document]]" in message
|
||||
|
||||
media_files, cleaned_message = BasePlatformAdapter.extract_media(message)
|
||||
mirror_text = cleaned_message.strip() or _describe_media_for_mirror(media_files)
|
||||
|
||||
@@ -277,6 +283,7 @@ def _handle_send(args):
|
||||
cleaned_message,
|
||||
thread_id=thread_id,
|
||||
media_files=media_files,
|
||||
force_document=force_document_attachments,
|
||||
)
|
||||
)
|
||||
if used_home_channel and isinstance(result, dict) and result.get("success"):
|
||||
@@ -437,7 +444,7 @@ async def _send_via_adapter(platform, pconfig, chat_id, chunk):
|
||||
return {"error": f"No live adapter for platform '{platform.value}'. Is the gateway running with this platform connected?"}
|
||||
|
||||
|
||||
async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, media_files=None):
|
||||
async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, media_files=None, force_document=False):
|
||||
"""Route a message to the appropriate platform sender.
|
||||
|
||||
Long messages are automatically chunked to fit within platform limits
|
||||
@@ -514,6 +521,7 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
|
||||
media_files=media_files if is_last else [],
|
||||
thread_id=thread_id,
|
||||
disable_link_previews=disable_link_previews,
|
||||
force_document=force_document,
|
||||
)
|
||||
if isinstance(result, dict) and result.get("error"):
|
||||
return result
|
||||
@@ -667,7 +675,7 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
|
||||
return last_result
|
||||
|
||||
|
||||
async def _send_telegram(token, chat_id, message, media_files=None, thread_id=None, disable_link_previews=False):
|
||||
async def _send_telegram(token, chat_id, message, media_files=None, thread_id=None, disable_link_previews=False, force_document=False):
|
||||
"""Send via Telegram Bot API (one-shot, no polling needed).
|
||||
|
||||
Applies markdown→MarkdownV2 formatting (same as the gateway adapter)
|
||||
@@ -750,7 +758,7 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No
|
||||
ext = os.path.splitext(media_path)[1].lower()
|
||||
try:
|
||||
with open(media_path, "rb") as f:
|
||||
if ext in _IMAGE_EXTS:
|
||||
if ext in _IMAGE_EXTS and not force_document:
|
||||
last_msg = await bot.send_photo(
|
||||
chat_id=int_chat_id, photo=f, **thread_kwargs
|
||||
)
|
||||
|
||||
@@ -283,11 +283,13 @@ def _find_skill(name: str) -> Optional[Dict[str, Any]]:
|
||||
external dirs configured via skills.external_dirs. Returns
|
||||
{"path": Path} or None.
|
||||
"""
|
||||
from agent.skill_utils import get_all_skills_dirs
|
||||
from agent.skill_utils import EXCLUDED_SKILL_DIRS, get_all_skills_dirs
|
||||
for skills_dir in get_all_skills_dirs():
|
||||
if not skills_dir.exists():
|
||||
continue
|
||||
for skill_md in skills_dir.rglob("SKILL.md"):
|
||||
if any(part in EXCLUDED_SKILL_DIRS for part in skill_md.parts):
|
||||
continue
|
||||
if skill_md.parent.name == name:
|
||||
return {"path": skill_md.parent}
|
||||
return None
|
||||
|
||||
+71
-11
@@ -28,6 +28,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
|
||||
@@ -36,6 +37,17 @@ from hermes_constants import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# fcntl is Unix-only; on Windows use msvcrt for file locking.
|
||||
msvcrt = None
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError: # pragma: no cover - platform-specific fallback
|
||||
fcntl = None
|
||||
try:
|
||||
import msvcrt
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
STATE_ACTIVE = "active"
|
||||
STATE_STALE = "stale"
|
||||
@@ -51,6 +63,39 @@ def _usage_file() -> Path:
|
||||
return _skills_dir() / ".usage.json"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _usage_file_lock():
|
||||
"""Serialize .usage.json read-modify-write cycles across processes."""
|
||||
lock_path = _usage_file().with_suffix(".json.lock")
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if fcntl is None and msvcrt is None:
|
||||
yield
|
||||
return
|
||||
|
||||
if msvcrt and (not lock_path.exists() or lock_path.stat().st_size == 0):
|
||||
lock_path.write_text(" ", encoding="utf-8")
|
||||
|
||||
fd = open(lock_path, "r+" if msvcrt else "a+")
|
||||
try:
|
||||
if fcntl:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX)
|
||||
else:
|
||||
fd.seek(0)
|
||||
msvcrt.locking(fd.fileno(), msvcrt.LK_LOCK, 1)
|
||||
yield
|
||||
finally:
|
||||
if fcntl:
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
elif msvcrt:
|
||||
try:
|
||||
fd.seek(0)
|
||||
msvcrt.locking(fd.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
fd.close()
|
||||
|
||||
|
||||
def _archive_dir() -> Path:
|
||||
return _skills_dir() / ".archive"
|
||||
|
||||
@@ -205,6 +250,19 @@ def list_agent_created_skill_names() -> List[str]:
|
||||
return sorted(set(names))
|
||||
|
||||
|
||||
def list_archived_skill_names() -> List[str]:
|
||||
"""Enumerate skills in ``~/.hermes/skills/.archive/``.
|
||||
|
||||
Archive layout is flat (``.archive/<skill>/``) as set by ``archive_skill``,
|
||||
so the directory name is the skill name. Used by ``hermes curator
|
||||
list-archived`` to help users pass a name to ``hermes curator restore``.
|
||||
"""
|
||||
archive_root = _archive_dir()
|
||||
if not archive_root.exists():
|
||||
return []
|
||||
return sorted({p.name for p in archive_root.iterdir() if p.is_dir()})
|
||||
|
||||
|
||||
def _read_skill_name(skill_md: Path, fallback: str) -> str:
|
||||
"""Parse the `name:` field from a SKILL.md YAML frontmatter."""
|
||||
try:
|
||||
@@ -328,13 +386,14 @@ def _mutate(skill_name: str, mutator) -> None:
|
||||
try:
|
||||
if not is_agent_created(skill_name):
|
||||
return
|
||||
data = load_usage()
|
||||
rec = data.get(skill_name)
|
||||
if not isinstance(rec, dict):
|
||||
rec = _empty_record()
|
||||
mutator(rec)
|
||||
data[skill_name] = rec
|
||||
save_usage(data)
|
||||
with _usage_file_lock():
|
||||
data = load_usage()
|
||||
rec = data.get(skill_name)
|
||||
if not isinstance(rec, dict):
|
||||
rec = _empty_record()
|
||||
mutator(rec)
|
||||
data[skill_name] = rec
|
||||
save_usage(data)
|
||||
except Exception as e:
|
||||
logger.debug("skill_usage._mutate(%s) failed: %s", skill_name, e, exc_info=True)
|
||||
|
||||
@@ -404,10 +463,11 @@ def forget(skill_name: str) -> None:
|
||||
if not skill_name:
|
||||
return
|
||||
try:
|
||||
data = load_usage()
|
||||
if skill_name in data:
|
||||
del data[skill_name]
|
||||
save_usage(data)
|
||||
with _usage_file_lock():
|
||||
data = load_usage()
|
||||
if skill_name in data:
|
||||
del data[skill_name]
|
||||
save_usage(data)
|
||||
except Exception as e:
|
||||
logger.debug("skill_usage.forget(%s) failed: %s", skill_name, e, exc_info=True)
|
||||
|
||||
|
||||
@@ -147,6 +147,102 @@ def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def is_always_blocked_url(url: str) -> bool:
|
||||
"""Return True when the URL targets an always-blocked endpoint.
|
||||
|
||||
This is the security floor — cloud metadata IPs / hostnames
|
||||
(169.254.169.254, metadata.google.internal, ECS task metadata, etc.)
|
||||
that have no legitimate agent use regardless of backend, routing, or
|
||||
the ``allow_private_urls`` toggle. Used by callers that bypass the
|
||||
full ``is_safe_url`` check for their own reasons (e.g. hybrid cloud
|
||||
browser routing to a local Chromium sidecar for private URLs) and
|
||||
still need to enforce the non-negotiable floor before letting the
|
||||
request proceed.
|
||||
|
||||
Returns True (= blocked) on:
|
||||
- Hostnames in ``_BLOCKED_HOSTNAMES``
|
||||
- IPs / networks in ``_ALWAYS_BLOCKED_IPS`` / ``_ALWAYS_BLOCKED_NETWORKS``
|
||||
- URLs whose hostname resolves to any of the above
|
||||
|
||||
Returns False (= not in the always-blocked floor) on:
|
||||
- Benign public / private / loopback URLs (whether or not they'd
|
||||
be blocked by the ordinary SSRF check)
|
||||
- DNS-resolution failures for non-sentinel hostnames (these are
|
||||
someone else's problem — the caller's ordinary fail-closed path
|
||||
will catch them if applicable)
|
||||
- Parse errors (caller decides fail-open vs fail-closed)
|
||||
|
||||
Intentionally narrower than ``is_safe_url``: only blocks the sentinel
|
||||
set, not ordinary private addresses. Callers that want the full
|
||||
SSRF check should still use ``is_safe_url``.
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
hostname = (parsed.hostname or "").strip().lower().rstrip(".")
|
||||
if not hostname:
|
||||
return False
|
||||
|
||||
# Blocked-hostname check fires regardless of DNS resolution
|
||||
if hostname in _BLOCKED_HOSTNAMES:
|
||||
logger.warning(
|
||||
"Blocked request to internal hostname (always-blocked floor): %s",
|
||||
hostname,
|
||||
)
|
||||
return True
|
||||
|
||||
# Literal IP → check directly against the always-blocked set
|
||||
try:
|
||||
ip = ipaddress.ip_address(hostname)
|
||||
except ValueError:
|
||||
ip = None
|
||||
|
||||
if ip is not None:
|
||||
if ip in _ALWAYS_BLOCKED_IPS or any(
|
||||
ip in net for net in _ALWAYS_BLOCKED_NETWORKS
|
||||
):
|
||||
logger.warning(
|
||||
"Blocked request to cloud metadata address "
|
||||
"(always-blocked floor): %s",
|
||||
hostname,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
# Hostname → resolve and check every answer. DNS failure is NOT
|
||||
# always-blocked (caller's ordinary path handles that).
|
||||
try:
|
||||
addr_info = socket.getaddrinfo(
|
||||
hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM
|
||||
)
|
||||
except socket.gaierror:
|
||||
return False
|
||||
|
||||
for _family, _, _, _, sockaddr in addr_info:
|
||||
ip_str = sockaddr[0]
|
||||
try:
|
||||
resolved = ipaddress.ip_address(ip_str)
|
||||
except ValueError:
|
||||
continue
|
||||
if resolved in _ALWAYS_BLOCKED_IPS or any(
|
||||
resolved in net for net in _ALWAYS_BLOCKED_NETWORKS
|
||||
):
|
||||
logger.warning(
|
||||
"Blocked request to cloud metadata address "
|
||||
"(always-blocked floor): %s -> %s",
|
||||
hostname,
|
||||
ip_str,
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as exc:
|
||||
# Parse failures or unexpected errors — don't claim the URL is
|
||||
# always-blocked. Caller decides what to do with a malformed URL.
|
||||
logger.debug("is_always_blocked_url error for %s: %s", url, exc)
|
||||
return False
|
||||
|
||||
|
||||
def _allows_private_ip_resolution(hostname: str, scheme: str) -> bool:
|
||||
"""Return True when a trusted HTTPS hostname may bypass IP-class blocking."""
|
||||
return scheme == "https" and hostname in _TRUSTED_PRIVATE_IP_HOSTS
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Brave Search web search provider (free tier).
|
||||
|
||||
Brave Search's Data-for-Search API offers a free tier (2,000 queries/mo at the
|
||||
time of writing) after signing up at https://brave.com/search/api/. This
|
||||
provider implements ``WebSearchProvider`` only — the Data-for-Search endpoint
|
||||
returns search results, it does not extract/crawl arbitrary URLs.
|
||||
|
||||
Configuration::
|
||||
|
||||
# ~/.hermes/.env
|
||||
BRAVE_SEARCH_API_KEY=your-subscription-token
|
||||
|
||||
# ~/.hermes/config.yaml
|
||||
web:
|
||||
search_backend: "brave-free"
|
||||
extract_backend: "firecrawl" # pair with an extract provider if needed
|
||||
|
||||
The API uses the ``X-Subscription-Token`` header. Free-tier keys are rate
|
||||
limited (1 qps) and capped at 2k queries/month; see the Brave dashboard for
|
||||
current quotas.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict
|
||||
|
||||
from tools.web_providers.base import WebSearchProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BRAVE_ENDPOINT = "https://api.search.brave.com/res/v1/web/search"
|
||||
|
||||
|
||||
class BraveFreeSearchProvider(WebSearchProvider):
|
||||
"""Search via the Brave Search API (free tier).
|
||||
|
||||
Requires ``BRAVE_SEARCH_API_KEY`` to be set. The value is passed as the
|
||||
``X-Subscription-Token`` header. No extract capability — pair with
|
||||
Firecrawl/Tavily/Exa/Parallel when you also need ``web_extract``.
|
||||
"""
|
||||
|
||||
def provider_name(self) -> str:
|
||||
return "brave-free"
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""Return True when ``BRAVE_SEARCH_API_KEY`` is set to a non-empty value."""
|
||||
return bool(os.getenv("BRAVE_SEARCH_API_KEY", "").strip())
|
||||
|
||||
def search(self, query: str, limit: int = 5) -> Dict[str, Any]:
|
||||
"""Execute a search against the Brave Search API.
|
||||
|
||||
Returns normalized results::
|
||||
|
||||
{
|
||||
"success": True,
|
||||
"data": {
|
||||
"web": [
|
||||
{
|
||||
"title": str,
|
||||
"url": str,
|
||||
"description": str,
|
||||
"position": int,
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
On failure returns ``{"success": False, "error": str}``.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
api_key = os.getenv("BRAVE_SEARCH_API_KEY", "").strip()
|
||||
if not api_key:
|
||||
return {"success": False, "error": "BRAVE_SEARCH_API_KEY is not set"}
|
||||
|
||||
# Brave's `count` is capped at 20.
|
||||
count = max(1, min(int(limit), 20))
|
||||
|
||||
try:
|
||||
resp = httpx.get(
|
||||
_BRAVE_ENDPOINT,
|
||||
params={"q": query, "count": count},
|
||||
headers={
|
||||
"X-Subscription-Token": api_key,
|
||||
"Accept": "application/json",
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
logger.warning("Brave Search HTTP error: %s", exc)
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Brave Search returned HTTP {exc.response.status_code}",
|
||||
}
|
||||
except httpx.RequestError as exc:
|
||||
logger.warning("Brave Search request error: %s", exc)
|
||||
return {"success": False, "error": f"Could not reach Brave Search: {exc}"}
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Brave Search response parse error: %s", exc)
|
||||
return {"success": False, "error": "Could not parse Brave Search response as JSON"}
|
||||
|
||||
raw_results = (data.get("web") or {}).get("results", []) or []
|
||||
truncated = raw_results[:limit]
|
||||
|
||||
web_results = [
|
||||
{
|
||||
"title": str(r.get("title", "")),
|
||||
"url": str(r.get("url", "")),
|
||||
"description": str(r.get("description", "")),
|
||||
"position": i + 1,
|
||||
}
|
||||
for i, r in enumerate(truncated)
|
||||
]
|
||||
|
||||
logger.info(
|
||||
"Brave Search '%s': %d results (from %d raw, limit %d)",
|
||||
query,
|
||||
len(web_results),
|
||||
len(raw_results),
|
||||
limit,
|
||||
)
|
||||
|
||||
return {"success": True, "data": {"web": web_results}}
|
||||
@@ -0,0 +1,98 @@
|
||||
"""DuckDuckGo web search provider via the ``ddgs`` Python package.
|
||||
|
||||
DuckDuckGo does not provide an official programmatic search API. The
|
||||
community-maintained `ddgs <https://pypi.org/project/ddgs/>`_ package (the
|
||||
renamed successor of ``duckduckgo-search``) scrapes DuckDuckGo's HTML results
|
||||
page and normalizes them. It implements ``WebSearchProvider`` only — there is
|
||||
no extract capability.
|
||||
|
||||
Configuration::
|
||||
|
||||
# No API key required. Enable by installing the package and pointing the
|
||||
# web backend at ddgs:
|
||||
pip install ddgs
|
||||
|
||||
# ~/.hermes/config.yaml
|
||||
web:
|
||||
search_backend: "ddgs"
|
||||
extract_backend: "firecrawl" # pair with an extract provider if needed
|
||||
|
||||
Rate limits are enforced server-side by DuckDuckGo. Expect intermittent
|
||||
``DuckDuckGoSearchException`` / 202 responses under heavy use; this provider
|
||||
surfaces them as ``{"success": False, "error": ...}`` rather than crashing
|
||||
the tool call.
|
||||
|
||||
See https://duckduckgo.com/?q=duckduckgo+tos for terms of use.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from tools.web_providers.base import WebSearchProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DDGSSearchProvider(WebSearchProvider):
|
||||
"""Search via the ``ddgs`` package (DuckDuckGo HTML scrape).
|
||||
|
||||
No API key required. The provider is considered "configured" when the
|
||||
``ddgs`` package is importable — there is nothing else to set up.
|
||||
"""
|
||||
|
||||
def provider_name(self) -> str:
|
||||
return "ddgs"
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""Return True when the ``ddgs`` package is importable.
|
||||
|
||||
Called at tool-registration time; must not perform network I/O.
|
||||
"""
|
||||
try:
|
||||
import ddgs # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
def search(self, query: str, limit: int = 5) -> Dict[str, Any]:
|
||||
"""Execute a DuckDuckGo search and return normalized results.
|
||||
|
||||
Returns ``{"success": True, "data": {"web": [...]}}`` on success or
|
||||
``{"success": False, "error": str}`` on failure (missing package,
|
||||
rate-limited, network error, etc.).
|
||||
"""
|
||||
try:
|
||||
from ddgs import DDGS # type: ignore
|
||||
except ImportError:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "ddgs package is not installed — run `pip install ddgs`",
|
||||
}
|
||||
|
||||
# DDGS().text yields at most `max_results` items; we cap defensively
|
||||
# in case the package ignores the hint.
|
||||
safe_limit = max(1, int(limit))
|
||||
|
||||
try:
|
||||
web_results = []
|
||||
with DDGS() as client:
|
||||
for i, hit in enumerate(client.text(query, max_results=safe_limit)):
|
||||
if i >= safe_limit:
|
||||
break
|
||||
url = str(hit.get("href") or hit.get("url") or "")
|
||||
web_results.append(
|
||||
{
|
||||
"title": str(hit.get("title", "")),
|
||||
"url": url,
|
||||
"description": str(hit.get("body", "")),
|
||||
"position": i + 1,
|
||||
}
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — ddgs raises its own exceptions
|
||||
logger.warning("DDGS search error: %s", exc)
|
||||
return {"success": False, "error": f"DuckDuckGo search failed: {exc}"}
|
||||
|
||||
logger.info("DDGS search '%s': %d results (limit %d)", query, len(web_results), limit)
|
||||
return {"success": True, "data": {"web": web_results}}
|
||||
@@ -5,10 +5,11 @@ It implements ``WebSearchProvider`` only — there is no extract capability.
|
||||
|
||||
Configuration::
|
||||
|
||||
# ~/.hermes/config.yaml (SEARXNG_URL is a URL, not a secret — use config.yaml not .env)
|
||||
SEARXNG_URL: http://localhost:8080
|
||||
# ~/.hermes/.env
|
||||
SEARXNG_URL=http://localhost:8080
|
||||
|
||||
# Use SearXNG for search, pair with any extract provider:
|
||||
# ~/.hermes/config.yaml
|
||||
web:
|
||||
search_backend: "searxng"
|
||||
extract_backend: "firecrawl"
|
||||
|
||||
+61
-9
@@ -126,18 +126,22 @@ def _get_backend() -> str:
|
||||
keys manually without running setup.
|
||||
"""
|
||||
configured = (_load_web_config().get("backend") or "").lower().strip()
|
||||
if configured in ("parallel", "firecrawl", "tavily", "exa", "searxng"):
|
||||
if configured in ("parallel", "firecrawl", "tavily", "exa", "searxng", "brave-free", "ddgs"):
|
||||
return configured
|
||||
|
||||
# Fallback for manual / legacy config — pick the highest-priority
|
||||
# available backend. Firecrawl also counts as available when the managed
|
||||
# tool gateway is configured for Nous subscribers.
|
||||
# Free-tier backends (searxng / brave-free / ddgs) trail the paid ones so
|
||||
# existing paid setups are unaffected.
|
||||
backend_candidates = (
|
||||
("firecrawl", _has_env("FIRECRAWL_API_KEY") or _has_env("FIRECRAWL_API_URL") or _is_tool_gateway_ready()),
|
||||
("parallel", _has_env("PARALLEL_API_KEY")),
|
||||
("tavily", _has_env("TAVILY_API_KEY")),
|
||||
("exa", _has_env("EXA_API_KEY")),
|
||||
("searxng", _has_env("SEARXNG_URL")),
|
||||
("brave-free", _has_env("BRAVE_SEARCH_API_KEY")),
|
||||
("ddgs", _ddgs_package_importable()),
|
||||
)
|
||||
for backend, available in backend_candidates:
|
||||
if available:
|
||||
@@ -196,8 +200,27 @@ def _is_backend_available(backend: str) -> bool:
|
||||
return _has_env("TAVILY_API_KEY")
|
||||
if backend == "searxng":
|
||||
return _has_env("SEARXNG_URL")
|
||||
if backend == "brave-free":
|
||||
return _has_env("BRAVE_SEARCH_API_KEY")
|
||||
if backend == "ddgs":
|
||||
return _ddgs_package_importable()
|
||||
return False
|
||||
|
||||
|
||||
def _ddgs_package_importable() -> bool:
|
||||
"""Return True when the ``ddgs`` Python package can be imported.
|
||||
|
||||
ddgs is the only backend whose availability is driven by a package
|
||||
presence rather than an env var / config entry. Wrapped in a helper
|
||||
so auto-detect and ``_is_backend_available`` share the same check
|
||||
(and tests can monkeypatch a single symbol).
|
||||
"""
|
||||
try:
|
||||
import ddgs # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
# ─── Firecrawl Client ────────────────────────────────────────────────────────
|
||||
|
||||
_firecrawl_client = None
|
||||
@@ -1200,6 +1223,26 @@ def web_search_tool(query: str, limit: int = 5) -> str:
|
||||
_debug.save()
|
||||
return result_json
|
||||
|
||||
if backend == "brave-free":
|
||||
from tools.web_providers.brave_free import BraveFreeSearchProvider
|
||||
response_data = BraveFreeSearchProvider().search(query, limit)
|
||||
debug_call_data["results_count"] = len(response_data.get("data", {}).get("web", []))
|
||||
result_json = json.dumps(response_data, indent=2, ensure_ascii=False)
|
||||
debug_call_data["final_response_size"] = len(result_json)
|
||||
_debug.log_call("web_search_tool", debug_call_data)
|
||||
_debug.save()
|
||||
return result_json
|
||||
|
||||
if backend == "ddgs":
|
||||
from tools.web_providers.ddgs import DDGSSearchProvider
|
||||
response_data = DDGSSearchProvider().search(query, limit)
|
||||
debug_call_data["results_count"] = len(response_data.get("data", {}).get("web", []))
|
||||
result_json = json.dumps(response_data, indent=2, ensure_ascii=False)
|
||||
debug_call_data["final_response_size"] = len(result_json)
|
||||
_debug.log_call("web_search_tool", debug_call_data)
|
||||
_debug.save()
|
||||
return result_json
|
||||
|
||||
if backend == "tavily":
|
||||
logger.info("Tavily search: '%s' (limit: %d)", query, limit)
|
||||
raw = _tavily_request("search", {
|
||||
@@ -1350,11 +1393,12 @@ async def web_extract_tool(
|
||||
"include_images": False,
|
||||
})
|
||||
results = _normalize_tavily_documents(raw, fallback_url=safe_urls[0] if safe_urls else "")
|
||||
elif backend == "searxng":
|
||||
# SearXNG is search-only — it cannot extract URL content
|
||||
elif backend in ("searxng", "brave-free", "ddgs"):
|
||||
# These backends are search-only — they cannot extract URL content
|
||||
_label = {"searxng": "SearXNG", "brave-free": "Brave Search (free tier)", "ddgs": "DuckDuckGo (ddgs)"}[backend]
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": "SearXNG is a search-only backend and cannot extract URL content. "
|
||||
"error": f"{_label} is a search-only backend and cannot extract URL content. "
|
||||
"Set web.extract_backend to firecrawl, tavily, exa, or parallel.",
|
||||
}, ensure_ascii=False)
|
||||
else:
|
||||
@@ -1732,10 +1776,11 @@ async def web_crawl_tool(
|
||||
_debug.save()
|
||||
return cleaned_result
|
||||
|
||||
# SearXNG is search-only — it cannot crawl
|
||||
if backend == "searxng":
|
||||
# SearXNG / Brave Search (free tier) / DuckDuckGo (ddgs) are search-only — they cannot crawl
|
||||
if backend in ("searxng", "brave-free", "ddgs"):
|
||||
_label = {"searxng": "SearXNG", "brave-free": "Brave Search (free tier)", "ddgs": "DuckDuckGo (ddgs)"}[backend]
|
||||
return json.dumps({
|
||||
"error": "SearXNG is a search-only backend and cannot crawl URLs. "
|
||||
"error": f"{_label} is a search-only backend and cannot crawl URLs. "
|
||||
"Set FIRECRAWL_API_KEY for crawling, or use web_search instead.",
|
||||
"success": False,
|
||||
}, ensure_ascii=False)
|
||||
@@ -2035,9 +2080,12 @@ def check_firecrawl_api_key() -> bool:
|
||||
def check_web_api_key() -> bool:
|
||||
"""Check whether the configured web backend is available."""
|
||||
configured = _load_web_config().get("backend", "").lower().strip()
|
||||
if configured in ("exa", "parallel", "firecrawl", "tavily", "searxng"):
|
||||
if configured in ("exa", "parallel", "firecrawl", "tavily", "searxng", "brave-free", "ddgs"):
|
||||
return _is_backend_available(configured)
|
||||
return any(_is_backend_available(backend) for backend in ("exa", "parallel", "firecrawl", "tavily", "searxng"))
|
||||
return any(
|
||||
_is_backend_available(backend)
|
||||
for backend in ("exa", "parallel", "firecrawl", "tavily", "searxng", "brave-free", "ddgs")
|
||||
)
|
||||
|
||||
|
||||
def check_auxiliary_model() -> bool:
|
||||
@@ -2074,6 +2122,10 @@ if __name__ == "__main__":
|
||||
print(" Using Tavily API (https://tavily.com)")
|
||||
elif backend == "searxng":
|
||||
print(f" Using SearXNG (search only): {os.getenv('SEARXNG_URL', '').strip()}")
|
||||
elif backend == "brave-free":
|
||||
print(" Using Brave Search free tier (search only)")
|
||||
elif backend == "ddgs":
|
||||
print(" Using DuckDuckGo via ddgs package (search only)")
|
||||
else:
|
||||
if firecrawl_url_available:
|
||||
print(f" Using self-hosted Firecrawl: {os.getenv('FIRECRAWL_API_URL').strip().rstrip('/')}")
|
||||
|
||||
Reference in New Issue
Block a user