Merge origin/main into bb/gui

Adopt main's web/ dashboard layout (apps/dashboard removed; web/ restored),
keep bb/gui's desktop CLI/update workspace handling, and preserve main's
mTLS/URL validation MCP changes. Dashboard backend is aligned to main with
only the intended STT provider quarantine/ElevenLabs override reapplied.
This commit is contained in:
Brooklyn Nicholson
2026-05-29 20:40:08 -05:00
1205 changed files with 39074 additions and 9768 deletions
+284 -94
View File
@@ -367,6 +367,13 @@ DANGEROUS_PATTERNS = [
# terminates all running agents mid-work.
(r'\bhermes\s+gateway\s+(stop|restart)\b', "stop/restart hermes gateway (kills running agents)"),
(r'\bhermes\s+update\b', "hermes update (restarts gateway, kills running agents)"),
# Docker container lifecycle — any user with docker.sock mounted (a common
# Docker Compose pattern) gives the agent the ability to restart/stop/kill
# containers without approval. These are agent-initiated lifecycle operations
# that should always require user consent, just like `hermes gateway restart`
# already does for the gateway process.
(r'\bdocker\s+compose\s+(restart|stop|kill|down)\b', "docker compose restart/stop/kill/down (container lifecycle)"),
(r'\bdocker\s+(restart|stop|kill)\b', "docker restart/stop/kill (container lifecycle)"),
# Gateway protection: never start gateway outside systemd management
(r'gateway\s+run\b.*(&\s*$|&\s*;|\bdisown\b|\bsetsid\b)', "start gateway outside systemd (use 'systemctl --user restart hermes-gateway')"),
(r'\bnohup\b.*gateway\s+run\b', "start gateway outside systemd (use 'systemctl --user restart hermes-gateway')"),
@@ -1050,6 +1057,107 @@ def _format_tirith_description(tirith_result: dict) -> str:
return "Security scan — " + "; ".join(parts)
def _await_gateway_decision(session_key: str, notify_cb, approval_data: dict,
*, surface: str = "gateway") -> dict:
"""Enqueue *approval_data*, notify the user, and block the calling agent
thread until the request is resolved or the gateway approval timeout
elapses — firing pre/post approval hooks and cleaning up the queue entry.
Shared by the terminal command guard (``check_all_command_guards``) and
the execute_code guard (``check_execute_code_guard``) so the fiddly
heartbeat-polling wait loop lives in one place.
Returns ``{"resolved": bool, "choice": str|None}`` on completion, or
``{"resolved": False, "choice": None, "notify_failed": True}`` if the
notify callback raised. Persistence of an approved choice and building
the final tool-facing result dict remain the caller's responsibility.
"""
command = approval_data.get("command", "")
description = approval_data.get("description", "")
primary_key = approval_data.get("pattern_key", "")
all_keys = approval_data.get("pattern_keys", [primary_key])
entry = _ApprovalEntry(approval_data)
with _lock:
_gateway_queues.setdefault(session_key, []).append(entry)
def _drop_entry() -> None:
with _lock:
queue = _gateway_queues.get(session_key, [])
if entry in queue:
queue.remove(entry)
if not queue:
_gateway_queues.pop(session_key, None)
# Notify plugins that an approval is being requested. Fires before the
# gateway notify callback so observers get the event in real time.
_fire_approval_hook(
"pre_approval_request",
command=command,
description=description,
pattern_key=primary_key,
pattern_keys=list(all_keys),
session_key=session_key,
surface=surface,
)
# Notify the user (bridges sync agent thread → async gateway)
try:
notify_cb(approval_data)
except Exception as exc:
logger.warning("Gateway approval notify failed: %s", exc)
_drop_entry()
return {"resolved": False, "choice": None, "notify_failed": True}
# Block until the user responds or timeout (default 5 min). Poll in short
# slices so we can fire activity heartbeats every ~10s to the agent's
# inactivity tracker — otherwise the gateway watchdog kills the agent
# while the user is still responding. Mirrors _wait_for_process() cadence.
timeout = _get_approval_config().get("gateway_timeout", 300)
try:
timeout = int(timeout)
except (ValueError, TypeError):
timeout = 300
try:
from tools.environments.base import touch_activity_if_due
except Exception: # pragma: no cover
touch_activity_if_due = None
_now = time.monotonic()
_deadline = _now + max(timeout, 0)
_activity_state = {"last_touch": _now, "start": _now}
resolved = False
while True:
_remaining = _deadline - time.monotonic()
if _remaining <= 0:
break
if entry.event.wait(timeout=min(1.0, _remaining)):
resolved = True
break
if touch_activity_if_due is not None:
touch_activity_if_due(_activity_state, "waiting for user approval")
_drop_entry()
choice = entry.result
# Normalize outcome for the post hook. Unresolved (timeout) and None both
# mean the user never responded; report that explicitly so plugins can
# distinguish timeout from explicit deny.
_outcome = "timeout" if not resolved else (choice if choice else "timeout")
_fire_approval_hook(
"post_approval_response",
command=command,
description=description,
pattern_key=primary_key,
pattern_keys=list(all_keys),
session_key=session_key,
surface=surface,
choice=_outcome,
)
return {"resolved": resolved, "choice": choice}
def check_all_command_guards(command: str, env_type: str,
approval_callback=None) -> dict:
"""Run all pre-exec security checks and return a single approval decision.
@@ -1200,113 +1308,27 @@ def check_all_command_guards(command: str, env_type: str,
if notify_cb is not None:
# --- Blocking gateway approval (queue-based) ---
# Each call gets its own _ApprovalEntry so parallel subagents
# and execute_code threads can block concurrently.
# Block the agent thread until the user responds; the notify +
# heartbeat wait loop is shared with check_execute_code_guard via
# _await_gateway_decision().
approval_data = {
"command": command,
"pattern_key": primary_key,
"pattern_keys": all_keys,
"description": combined_desc,
}
entry = _ApprovalEntry(approval_data)
with _lock:
_gateway_queues.setdefault(session_key, []).append(entry)
# Notify plugins that an approval is being requested. Fires before
# the gateway notify callback so observers (e.g. macOS notifier
# plugins, audit logs, Slack alerts) get the event in real time.
_fire_approval_hook(
"pre_approval_request",
command=command,
description=combined_desc,
pattern_key=primary_key,
pattern_keys=list(all_keys),
session_key=session_key,
surface="gateway",
decision = _await_gateway_decision(
session_key, notify_cb, approval_data, surface="gateway"
)
# Notify the user (bridges sync agent thread → async gateway)
try:
notify_cb(approval_data)
except Exception as exc:
logger.warning("Gateway approval notify failed: %s", exc)
with _lock:
queue = _gateway_queues.get(session_key, [])
if entry in queue:
queue.remove(entry)
if not queue:
_gateway_queues.pop(session_key, None)
if decision.get("notify_failed"):
return {
"approved": False,
"message": "BLOCKED: Failed to send approval request to user. Do NOT retry.",
"pattern_key": primary_key,
"description": combined_desc,
}
# Block until the user responds or timeout (default 5 min).
# Poll in short slices so we can fire activity heartbeats every
# ~10s to the agent's inactivity tracker. Without this, the
# blocking event.wait() never touches activity, and the
# gateway's inactivity watchdog (agent.gateway_timeout, default
# 1800s) kills the agent while the user is still responding to
# the approval prompt. Mirrors the _wait_for_process() cadence
# in tools/environments/base.py.
timeout = _get_approval_config().get("gateway_timeout", 300)
try:
timeout = int(timeout)
except (ValueError, TypeError):
timeout = 300
try:
from tools.environments.base import touch_activity_if_due
except Exception: # pragma: no cover
touch_activity_if_due = None
_now = time.monotonic()
_deadline = _now + max(timeout, 0)
_activity_state = {"last_touch": _now, "start": _now}
resolved = False
while True:
_remaining = _deadline - time.monotonic()
if _remaining <= 0:
break
# 1s poll slice — the event is set immediately when the
# user responds, so slice length only controls heartbeat
# cadence, not user-visible responsiveness.
if entry.event.wait(timeout=min(1.0, _remaining)):
resolved = True
break
if touch_activity_if_due is not None:
touch_activity_if_due(
_activity_state, "waiting for user approval"
)
# Clean up this entry from the queue
with _lock:
queue = _gateway_queues.get(session_key, [])
if entry in queue:
queue.remove(entry)
if not queue:
_gateway_queues.pop(session_key, None)
choice = entry.result
# Normalize outcome for the post hook. Unresolved (timeout) and
# None both mean the user never responded; report that explicitly
# so plugins can distinguish timeout from explicit deny.
_outcome = (
"timeout" if not resolved
else (choice if choice else "timeout")
)
_fire_approval_hook(
"post_approval_response",
command=command,
description=combined_desc,
pattern_key=primary_key,
pattern_keys=list(all_keys),
session_key=session_key,
surface="gateway",
choice=_outcome,
)
resolved = decision["resolved"]
choice = decision["choice"]
if not resolved or choice is None or choice == "deny":
# Consent contract: silence is NOT consent, and an explicit
@@ -1430,5 +1452,173 @@ def check_all_command_guards(command: str, env_type: str,
"user_approved": True, "description": combined_desc}
def check_execute_code_guard(code: str, env_type: str) -> dict:
"""Approve an execute_code script before its child process is spawned.
execute_code runs arbitrary local Python — the script can call
``subprocess``, ``os.system``, ``ctypes``, or other process/file APIs
directly, none of which pass through ``terminal()`` /
``DANGEROUS_PATTERNS``. In gateway/ask contexts we fail closed by approving
the script as a whole before it runs (#30882). Returns the same dict
contract as ``check_all_command_guards``.
Scope (documented limitation, #30882): in a purely local non-interactive
non-gateway session (no TTY, not gateway, not cron-deny) this returns
approved — matching the existing terminal auto-approve contract. The
hardline floor still blocks catastrophic ``terminal()`` commands the script
issues; running arbitrary code headlessly without any approval surface is
trusted-by-config (set a gateway/ask surface or ``approvals.cron_mode`` to
require approval).
"""
pattern_key = "execute_code"
description = (
"execute_code script execution. The script can spawn subprocesses or "
"mutate files without passing through terminal command approval; "
"approval is one-shot for this run."
)
# Isolated backends already sandbox the child — matches the container skip
# in check_all_command_guards / check_dangerous_command.
if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}:
return {"approved": True, "message": None}
# --yolo or approvals.mode=off: bypass (session- or process-scoped).
approval_mode = _get_approval_mode()
if _YOLO_MODE_FROZEN or is_current_session_yolo_enabled() or approval_mode == "off":
return {"approved": True, "message": None}
is_gateway = _is_gateway_approval_context()
is_ask = env_var_enabled("HERMES_EXEC_ASK")
# Cron: no user is present to approve arbitrary code.
if env_var_enabled("HERMES_CRON_SESSION"):
if _get_cron_approval_mode() == "deny":
return {
"approved": False,
"message": (
"BLOCKED: execute_code runs arbitrary local Python "
"(including subprocess calls that bypass shell-string "
"approval checks). Cron jobs run without a user present "
"to approve it. Use normal tools instead, or set "
"approvals.cron_mode: approve only if this cron profile "
"is intentionally trusted."
),
"pattern_key": pattern_key,
"description": description,
"outcome": "blocked",
"user_consent": False,
}
return {"approved": True, "message": None}
# Only gateway/ask contexts get the one-shot whole-script approval.
# * CLI interactive: the script's terminal() calls are guarded per-call
# (context now propagates into the RPC thread, #33057); a whole-script
# prompt would fire on every execute_code call.
# * Local non-interactive non-gateway: documented limitation above.
if not is_gateway and not is_ask:
return {"approved": True, "message": None}
session_key = get_current_session_key()
# Built only now (past the early-return gates) so the common non-approval
# paths don't pay to copy a potentially-large script into this string.
command = f"execute_code <<'PY'\n{code}\nPY"
# Smart mode: ask the aux LLM about the whole script. An APPROVE here only
# suppresses the redundant whole-script prompt; the per-call terminal()
# guards (restored by context propagation) still run independently.
if approval_mode == "smart":
verdict = _smart_approve(command, description)
if verdict == "approve":
logger.debug("Smart approval: auto-approved execute_code for session %s",
session_key)
return {"approved": True, "message": None,
"smart_approved": True, "description": description}
if verdict == "deny":
return {
"approved": False,
"message": ("BLOCKED by smart approval: execute_code script "
"execution was assessed as genuinely dangerous. "
"Do NOT retry."),
"smart_denied": True,
"pattern_key": pattern_key,
"description": description,
"outcome": "denied",
"user_consent": False,
}
# verdict == "escalate" → fall through to manual approval
notify_cb = None
with _lock:
notify_cb = _gateway_notify_cbs.get(session_key)
if notify_cb is None:
# No gateway callback registered (e.g. ask-mode without a notifier):
# surface a pending approval for backward compatibility.
submit_pending(session_key, {
"command": command,
"pattern_key": pattern_key,
"pattern_keys": [pattern_key],
"description": description,
})
return {
"approved": False,
"pattern_key": pattern_key,
"status": "pending_approval",
"approval_pending": True,
"command": command,
"description": description,
"message": (
f"⚠️ {description}. Asking the user for approval.\n\n"
f"**Code:**\n```python\n{code}\n```"
),
}
approval_data = {
"command": command,
"pattern_key": pattern_key,
"pattern_keys": [pattern_key],
"description": description,
}
decision = _await_gateway_decision(
session_key, notify_cb, approval_data, surface="gateway"
)
if decision.get("notify_failed"):
return {
"approved": False,
"message": ("BLOCKED: Failed to send execute_code approval request "
"to user. Do NOT retry."),
"pattern_key": pattern_key,
"description": description,
"outcome": "notify_failed",
"user_consent": False,
}
resolved = decision["resolved"]
choice = decision["choice"]
if not resolved or choice is None or choice == "deny":
reason = "timed out without user response" if not resolved else "denied by user"
addendum = " Silence is not consent." if not resolved else ""
return {
"approved": False,
"message": (
f"BLOCKED: execute_code script {reason}. The user has NOT "
f"consented to running this code. Do NOT retry, do NOT rephrase "
f"the script, and do NOT attempt the same outcome via a "
f"different tool.{addendum}"
),
"pattern_key": pattern_key,
"description": description,
"outcome": "timeout" if not resolved else "denied",
"user_consent": False,
}
# Approved — one-shot only. Deliberately NO approve_session/approve_permanent:
# each execute_code script is distinct arbitrary code, so approval never
# persists to future scripts.
return {"approved": True, "message": None,
"user_approved": True, "description": description}
# Load permanent allowlist from config on module import
load_permanent_allowlist()
+99 -4
View File
@@ -18,6 +18,9 @@ Setup::
docker run -p 9377:9377 -e CAMOFOX_PORT=9377 jo-inc/camofox-browser
Then set ``CAMOFOX_URL=http://localhost:9377`` in ``~/.hermes/.env``.
For Docker Camofox, optionally set ``CAMOFOX_REWRITE_LOOPBACK_URLS=true``
so page URLs like ``http://127.0.0.1:3000`` are opened inside the
container as ``http://host.docker.internal:3000``.
"""
from __future__ import annotations
@@ -29,6 +32,7 @@ import os
import threading
import uuid
from typing import Any, Dict, Optional
from urllib.parse import SplitResult, urlsplit, urlunsplit
import requests
@@ -159,6 +163,89 @@ def _adopt_existing_tab_enabled(camofox_cfg: Dict[str, Any]) -> bool:
return bool(camofox_cfg.get("adopt_existing_tab"))
def _loopback_rewrite_enabled(camofox_cfg: Dict[str, Any]) -> bool:
"""Return whether loopback navigation URLs should be rewritten for Docker.
``CAMOFOX_URL`` itself often points at a host-published Docker port such as
``http://127.0.0.1:9377``. That is correct for Hermes talking to the
Camofox control API, but a page URL like ``http://127.0.0.1:3000`` is opened
by the browser *inside* the Docker container. In that context loopback
points at the container, not the host running the web app.
The rewrite is opt-in because non-Docker Camofox installs run the browser on
the host, where loopback URLs are already correct.
"""
env_value = _env_flag("CAMOFOX_REWRITE_LOOPBACK_URLS")
if env_value is not None:
return env_value
return bool(camofox_cfg.get("rewrite_loopback_urls"))
def _loopback_rewrite_host(camofox_cfg: Dict[str, Any]) -> str:
"""Return the host alias used when rewriting loopback page URLs."""
return (
os.getenv("CAMOFOX_LOOPBACK_HOST_ALIAS", "").strip()
or str(camofox_cfg.get("loopback_host_alias") or "").strip()
or "host.docker.internal"
)
def _is_loopback_hostname(hostname: Optional[str]) -> bool:
"""Return True for localhost/127.0.0.0/8/::1-style hostnames."""
if not hostname:
return False
host = hostname.strip().strip("[]").lower()
if host in {"localhost", "localhost.localdomain"}:
return True
try:
import ipaddress
return ipaddress.ip_address(host).is_loopback
except ValueError:
return False
def _rewrite_loopback_url_for_camofox(url: str) -> tuple[str, Optional[Dict[str, str]]]:
"""Rewrite loopback page URLs for Docker-hosted Camofox, if configured.
Returns ``(rewritten_url, metadata)``. ``metadata`` is present only when a
rewrite happened so the tool result can disclose the change to the model.
"""
camofox_cfg = _get_camofox_config()
if not _loopback_rewrite_enabled(camofox_cfg):
return url, None
try:
parsed = urlsplit(url)
except ValueError:
return url, None
if parsed.scheme not in {"http", "https"} or not _is_loopback_hostname(parsed.hostname):
return url, None
alias = _loopback_rewrite_host(camofox_cfg)
if not alias:
return url, None
userinfo = ""
if parsed.username:
userinfo = parsed.username
if parsed.password:
userinfo += f":{parsed.password}"
userinfo += "@"
host_part = f"[{alias}]" if ":" in alias and not alias.startswith("[") else alias
port_part = f":{parsed.port}" if parsed.port else ""
rewritten = urlunsplit(
SplitResult(parsed.scheme, f"{userinfo}{host_part}{port_part}", parsed.path, parsed.query, parsed.fragment)
)
return rewritten, {
"from": parsed.hostname or "",
"to": alias,
"original_url": url,
"rewritten_url": rewritten,
}
# ---------------------------------------------------------------------------
# Session management
# ---------------------------------------------------------------------------
@@ -336,23 +423,31 @@ def _delete(path: str, body: dict = None, timeout: int = _DEFAULT_TIMEOUT) -> di
def camofox_navigate(url: str, task_id: Optional[str] = None) -> str:
"""Navigate to a URL via Camofox."""
try:
browser_url, rewrite_info = _rewrite_loopback_url_for_camofox(url)
session = _get_session(task_id)
if not session["tab_id"]:
# Create tab with the target URL directly
session = _ensure_tab(task_id, url)
data = {"ok": True, "url": url}
session = _ensure_tab(task_id, browser_url)
data = {"ok": True, "url": browser_url}
else:
# Navigate existing tab
data = _post(
f"/tabs/{session['tab_id']}/navigate",
{"userId": session["user_id"], "url": url},
{"userId": session["user_id"], "url": browser_url},
timeout=60,
)
result = {
"success": True,
"url": data.get("url", url),
"url": data.get("url", browser_url),
"title": data.get("title", ""),
}
if rewrite_info:
result["requested_url"] = url
result["url_rewrite"] = rewrite_info
result["warning"] = (
"Rewrote loopback URL for Docker-hosted Camofox: "
f"{rewrite_info['from']} -> {rewrite_info['to']}"
)
vnc = get_vnc_url()
if vnc:
result["vnc_url"] = vnc
-1
View File
@@ -257,7 +257,6 @@ def _browser_cdp_via_supervisor(
)
# Dispatch onto the supervisor's loop.
import asyncio as _asyncio
loop = supervisor._loop # type: ignore[attr-defined]
if loop is None or not loop.is_running():
return tool_error(
+44 -14
View File
@@ -33,8 +33,8 @@ Environment Variables:
requires Scale Plan (default: "false")
- BROWSERBASE_KEEP_ALIVE: Enable keepAlive for session reconnection after disconnects,
requires paid plan (default: "true")
- BROWSERBASE_SESSION_TIMEOUT: Custom session timeout in milliseconds. Set to extend
beyond project default. Common values: 600000 (10min), 1800000 (30min) (default: none)
- BROWSERBASE_SESSION_TIMEOUT: Custom session timeout in seconds (max 21600 = 6h).
Set to extend beyond project default. Common values: 600 (10min), 1800 (30min) (default: none)
Usage:
from tools.browser_tool import browser_navigate, browser_snapshot, browser_click
@@ -55,7 +55,6 @@ import json
import logging
import os
import re
import signal
import subprocess
import shutil
import sys
@@ -63,7 +62,7 @@ import tempfile
import threading
import time
import requests
from typing import Dict, Any, Optional, List, Tuple
from typing import Dict, Any, Optional, List, Tuple, Union
from pathlib import Path
from agent.auxiliary_client import call_llm
from hermes_constants import get_hermes_home
@@ -1579,7 +1578,7 @@ BROWSER_TOOL_SCHEMAS = [
},
{
"name": "browser_vision",
"description": "Take a screenshot of the current page and analyze it with vision AI. Use this when you need to visually understand what's on the page - especially useful for CAPTCHAs, visual verification challenges, complex layouts, or when the text snapshot doesn't capture important visual information. Returns both the AI analysis and a screenshot_path that you can share with the user by including MEDIA:<screenshot_path> in your response. Requires browser_navigate to be called first.",
"description": "Take a screenshot of the current page so you can inspect it visually. Use this when you need to understand what the page looks like - especially for CAPTCHAs, visual verification challenges, complex layouts, or cases where the text snapshot misses important visual information. When your active model has native vision, the screenshot is attached to your context directly and you inspect it on the next turn; otherwise Hermes falls back to an auxiliary vision model and returns a text analysis. Includes a screenshot_path that you can share with the user by including MEDIA:<screenshot_path> in your response. Requires browser_navigate to be called first.",
"parameters": {
"type": "object",
"properties": {
@@ -3045,17 +3044,19 @@ def browser_get_images(task_id: Optional[str] = None) -> str:
return json.dumps(_copy_fallback_warning(response, result), ensure_ascii=False)
def browser_vision(question: str, annotate: bool = False, task_id: Optional[str] = None) -> str:
def browser_vision(question: str, annotate: bool = False, task_id: Optional[str] = None) -> Union[str, Dict[str, Any]]:
"""
Take a screenshot of the current page and analyze it with vision AI.
Take a screenshot of the current page for visual inspection.
This tool captures what's visually displayed in the browser and sends it
to Gemini for analysis. Useful for understanding visual content that the
text-based snapshot may not capture (CAPTCHAs, verification challenges,
images, complex layouts, etc.).
Captures what's visually displayed in the browser. When the active model
supports native vision, the screenshot is attached directly to the
conversation so the model can inspect it on the next turn; otherwise Hermes
falls back to the auxiliary vision model and returns a text analysis. Useful
for visual content the text-based snapshot may not capture (CAPTCHAs,
verification challenges, images, complex layouts, etc.).
The screenshot is saved persistently and its file path is returned alongside
the analysis, so it can be shared with users via MEDIA:<path> in the response.
The screenshot is saved persistently and its file path is returned so it
can be shared with users via MEDIA:<path> in the response.
Args:
question: What you want to know about the page visually
@@ -3063,7 +3064,8 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str]
task_id: Task identifier for session isolation
Returns:
JSON string with vision analysis results and screenshot_path
A JSON string with vision analysis results and screenshot_path, or a
multimodal tool-result envelope carrying the screenshot and metadata.
"""
if _is_camofox_mode():
from tools.browser_camofox import camofox_vision
@@ -3188,6 +3190,34 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str]
_screenshot_b64 = base64.b64encode(_screenshot_bytes).decode("ascii")
data_url = f"data:image/png;base64,{_screenshot_b64}"
# Fast path: when native image routing is in effect for the active main
# model, attach the screenshot directly instead of describing it through
# an auxiliary vision LLM. The model inspects the pixels on its next
# turn — no aux call, no information loss. Consistent with vision_analyze.
from tools.vision_tools import (
_build_native_vision_tool_result,
_should_use_native_vision_fast_path,
)
if _should_use_native_vision_fast_path():
native_result = _build_native_vision_tool_result(
image_url=str(screenshot_path),
question=question,
image_data_url=data_url,
image_size_bytes=len(_screenshot_bytes),
)
meta = native_result.setdefault("meta", {})
meta["screenshot_path"] = str(screenshot_path)
if _lp_fallback_warning:
meta["fallback_warning"] = _lp_fallback_warning
if annotate and result.get("data", {}).get("annotations"):
meta["annotations"] = result["data"]["annotations"]
native_result["text_summary"] = (
f"{native_result.get('text_summary', '')} "
f"Screenshot path: {screenshot_path}"
).strip()
return native_result
vision_prompt = (
f"You are analyzing a screenshot of a web browser.\n\n"
f"User's question: {question}\n\n"
+74 -10
View File
@@ -35,7 +35,6 @@ import logging
import os
import platform
import shlex
import signal
import socket
import subprocess
import sys
@@ -47,6 +46,8 @@ import uuid
_IS_WINDOWS = platform.system() == "Windows"
from typing import Any, Dict, List, Optional
from tools.thread_context import propagate_context_to_thread
# Availability gate. On Windows we fall back to loopback TCP for the
# sandbox RPC transport (AF_UNIX is unreliable on Windows Python) — see
# ``_use_tcp_rpc`` in ``_execute_local`` below. That makes execute_code
@@ -75,13 +76,30 @@ MAX_STDERR_BYTES = 10_000 # 10 KB
# Environment variable scrubbing rules (shared between the local + remote
# backends). Secret-substring block is applied first; anything left must
# match either a safe prefix or, on Windows, an OS-essential name.
# match a safe prefix, the operational HERMES_ allowlist, or (on Windows) an
# OS-essential name.
#
# NB: the broad "HERMES_" prefix was deliberately removed (#27303) — it leaked
# HERMES_*-named config that lacks a secret substring (e.g. HERMES_BASE_URL,
# HERMES_KANBAN_DB, HERMES_*_WEBHOOK). The child only needs the few
# location/profile vars in _HERMES_CHILD_ALLOWED below; HERMES_RPC_SOCKET /
# HERMES_RPC_DIR / TZ / HOME are injected explicitly after scrubbing.
_SAFE_ENV_PREFIXES = ("PATH", "HOME", "USER", "LANG", "LC_", "TERM",
"TMPDIR", "TMP", "TEMP", "SHELL", "LOGNAME",
"XDG_", "PYTHONPATH", "VIRTUAL_ENV", "CONDA",
"HERMES_")
"XDG_", "PYTHONPATH", "VIRTUAL_ENV", "CONDA")
_SECRET_SUBSTRINGS = ("KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL",
"PASSWD", "AUTH")
"PASSWD", "AUTH", "DSN", "WEBHOOK")
# Operational HERMES_* vars the child legitimately needs by exact name — these
# are non-secret runtime-location flags (the same set hermes_cli treats as the
# runtime location) that repo-root modules a sandbox script imports may read at
# import time. None match _SECRET_SUBSTRINGS.
_HERMES_CHILD_ALLOWED = frozenset({
"HERMES_HOME",
"HERMES_PROFILE",
"HERMES_CONFIG",
"HERMES_ENV",
})
# Windows-only: a handful of variables are required by the OS/CRT itself.
# Without them, even stdlib calls like ``socket.socket()`` fail with
@@ -120,9 +138,10 @@ def _scrub_child_env(source_env, is_passthrough=None, is_windows=None):
Rules (order matters):
1. Passthrough vars (skill- or config-declared) always pass.
2. Secret-substring names (KEY/TOKEN/etc.) are blocked.
2. Secret-substring names (KEY/TOKEN/DSN/WEBHOOK/etc.) are blocked.
3. Names matching a safe prefix pass.
4. On Windows, a small OS-essential allowlist passes by exact name
4. Operational HERMES_* vars (_HERMES_CHILD_ALLOWED) pass by exact name.
5. On Windows, a small OS-essential allowlist passes by exact name
— without these the child can't even create a socket or spawn a
subprocess.
@@ -139,6 +158,14 @@ def _scrub_child_env(source_env, is_passthrough=None, is_windows=None):
is_windows = _IS_WINDOWS
scrubbed = {}
# Non-secret HERMES_* vars dropped by the tightened allowlist (#27303). The
# broad "HERMES_" prefix used to pass these through; now only the
# operational set does. The drop is intentional (those vars can carry
# config like HERMES_KANBAN_DB / HERMES_BASE_URL), but a sandbox script
# that imports a repo module reading one at import time would otherwise see
# it silently unset. Surface the drop once so the behavior change is
# diagnosable and points at the env_passthrough opt-in escape hatch.
_dropped_hermes = []
for k, v in source_env.items():
if is_passthrough(k):
scrubbed[k] = v
@@ -148,8 +175,25 @@ def _scrub_child_env(source_env, is_passthrough=None, is_windows=None):
if any(k.startswith(p) for p in _SAFE_ENV_PREFIXES):
scrubbed[k] = v
continue
if k in _HERMES_CHILD_ALLOWED:
scrubbed[k] = v
continue
if is_windows and k.upper() in _WINDOWS_ESSENTIAL_ENV_VARS:
scrubbed[k] = v
continue
if k.startswith("HERMES_"):
# Non-secret (secrets were already dropped above) and not in any
# allowlist — a deliberately-dropped HERMES_* var.
_dropped_hermes.append(k)
if _dropped_hermes:
logger.debug(
"execute_code: dropped %d non-allowlisted HERMES_* var(s) from the "
"sandbox child env (%s). This is intentional hardening (#27303); if "
"a sandbox script legitimately needs one, declare it via "
"env_passthrough in the skill/config so it passes by explicit opt-in.",
len(_dropped_hermes),
", ".join(sorted(_dropped_hermes)),
)
return scrubbed
@@ -888,9 +932,11 @@ def _execute_remote(
_ship_file_to_remote(env, f"{sandbox_dir}/hermes_tools.py", tools_src)
_ship_file_to_remote(env, f"{sandbox_dir}/script.py", code)
# Start RPC polling thread
# Wrapped so the thread inherits the turn's approval context + callbacks
# (see tools.thread_context) — else sandbox RPC tool calls lose approval
# routing (#33057).
rpc_thread = threading.Thread(
target=_rpc_poll_loop,
target=propagate_context_to_thread(_rpc_poll_loop),
args=(
env, f"{sandbox_dir}/rpc", effective_task_id,
tool_call_log, tool_call_counter, max_tool_calls,
@@ -1050,6 +1096,21 @@ def execute_code(
# Dispatch: remote backends use file-based RPC, local uses UDS
from tools.terminal_tool import _get_env_config
env_type = _get_env_config()["env_type"]
# execute_code runs arbitrary Python (subprocess/os.system/...) that never
# passes through terminal()/DANGEROUS_PATTERNS, so guard the whole script
# here before either dispatch path spawns it. Runs synchronously in the
# caller (tool-executor) thread, which holds the session context (#30882).
from tools.approval import check_execute_code_guard
_guard = check_execute_code_guard(code, env_type)
if not _guard.get("approved", False):
return json.dumps({
"status": "error",
"error": _guard.get("message") or "execute_code blocked by approval guard.",
"tool_calls_made": 0,
"duration_seconds": 0,
}, ensure_ascii=False)
if env_type != "local":
return _execute_remote(code, task_id, enabled_tools)
@@ -1136,8 +1197,11 @@ def execute_code(
os.chmod(sock_path, 0o600)
server_sock.listen(1)
# Wrapped so the thread inherits the turn's approval context + callbacks
# (see tools.thread_context) — else gateway sandbox tool calls silently
# auto-approve dangerous commands (#33057, #30882).
rpc_thread = threading.Thread(
target=_rpc_server_loop,
target=propagate_context_to_thread(_rpc_server_loop),
args=(
server_sock, task_id, tool_call_log,
tool_call_counter, max_tool_calls, sandbox_tools,
-33
View File
@@ -22,13 +22,10 @@ import base64
import json
import logging
import os
import platform
import re
import shutil
import subprocess
import sys
import threading
from concurrent.futures import Future
from typing import Any, Dict, List, Optional, Tuple
from tools.computer_use.backend import (
@@ -81,10 +78,6 @@ def _is_macos() -> bool:
return sys.platform == "darwin"
def _is_arm_mac() -> bool:
return _is_macos() and platform.machine() == "arm64"
def cua_driver_binary_available() -> bool:
"""True if `cua-driver` is on $PATH or HERMES_CUA_DRIVER_CMD resolves."""
return bool(shutil.which(_CUA_DRIVER_CMD))
@@ -707,29 +700,3 @@ class CuaDriverBackend(ComputerUseBackend):
message = data
return ActionResult(ok=ok, action=name, message=message,
meta=data if isinstance(data, dict) else {})
def _parse_element(d: Dict[str, Any]) -> UIElement:
bounds = d.get("bounds") or (0, 0, 0, 0)
if isinstance(bounds, dict):
bounds = (
int(bounds.get("x", 0)),
int(bounds.get("y", 0)),
int(bounds.get("w", bounds.get("width", 0))),
int(bounds.get("h", bounds.get("height", 0))),
)
elif isinstance(bounds, (list, tuple)) and len(bounds) == 4:
bounds = tuple(int(v) for v in bounds)
else:
bounds = (0, 0, 0, 0)
return UIElement(
index=int(d.get("index", 0)),
role=str(d.get("role", "") or ""),
label=str(d.get("label", "") or ""),
bounds=bounds, # type: ignore[arg-type]
app=str(d.get("app", "") or ""),
pid=int(d.get("pid", 0) or 0),
window_id=int(d.get("windowId", 0) or 0),
attributes={k: v for k, v in d.items()
if k not in {"index", "role", "label", "bounds", "app", "pid", "windowId"}},
)
-2
View File
@@ -7,7 +7,6 @@ Compatibility wrappers remain for direct Python callers and legacy tests.
import json
import logging
import os
import re
import sys
from pathlib import Path
@@ -23,7 +22,6 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
from cron.jobs import (
AmbiguousJobReference,
create_job,
get_job,
list_jobs,
parse_schedule,
pause_job,
+247
View File
@@ -0,0 +1,247 @@
"""Local-environment toolchain probe for the system prompt.
When the terminal backend is local (the agent's tools run on the same
machine as Hermes itself), we surface a single deterministic line about
Python tooling state so models don't have to discover it by hitting
walls. Common failure modes this addresses:
* Hermes ships under one Python (e.g. 3.11 in a bundled venv) while the
user's login shell has a different one (e.g. 3.12 system). ``pip``
resolved from PATH may not match ``python3 -m pip``.
* The bundled-venv Python has no pip module installed ``python3 -m
pip`` returns ``No module named pip``.
* The system Python is PEP-668 externally-managed naive
``pip install`` fails with ``error: externally-managed-environment``.
The probe is cheap (a handful of subprocess calls, ~50ms total),
cached for the lifetime of the process, and emits **at most one
short line** when something non-default is detected. When the
environment looks normal (python3+pip both present and matched, no
PEP 668), it emits nothing no token cost.
Remote terminal backends (docker, modal, ssh, ) are skipped: the
host's Python state is irrelevant when tools run inside a sandbox.
The sandbox has its own existing probe (``_probe_remote_backend``)
in ``agent/prompt_builder.py``.
Toggle via ``agent.environment_probe`` in config.yaml (default True).
"""
from __future__ import annotations
import logging
import os
import shutil
import subprocess
import sys
import threading
from typing import Optional
logger = logging.getLogger(__name__)
# Module-level cache. The probe result is deterministic for the
# lifetime of the process — Python install state doesn't change
# mid-session in any way that would matter for the system prompt.
_CACHE_LOCK = threading.Lock()
_CACHED_LINE: Optional[str] = None # None = not probed yet; "" = probed, nothing to say.
# Remote backends — keep in sync with agent/prompt_builder.py:_REMOTE_TERMINAL_BACKENDS.
# Duplicated rather than imported to avoid a circular import (prompt_builder
# imports nothing from tools).
_REMOTE_BACKENDS = frozenset({
"docker", "singularity", "modal", "daytona", "ssh", "managed_modal",
})
def _run(cmd: list[str], timeout: float = 3.0) -> tuple[int, str, str]:
"""Run a short subprocess. Returns (returncode, stdout, stderr).
Failures (binary missing, timeout, OSError) return (-1, "", "<reason>").
"""
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
return result.returncode, (result.stdout or "").strip(), (result.stderr or "").strip()
except FileNotFoundError:
return -1, "", "not found"
except subprocess.TimeoutExpired:
return -1, "", "timeout"
except OSError as exc:
return -1, "", f"oserror: {exc}"
def _python_version_of(binary: str) -> Optional[str]:
"""Return a short version string like ``3.12.4`` for ``binary``, or None."""
if not shutil.which(binary):
return None
rc, out, err = _run([binary, "-c", "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}')"])
if rc == 0 and out:
return out
return None
def _has_pip_module(binary: str) -> bool:
"""True if ``<binary> -m pip --version`` succeeds."""
if not shutil.which(binary):
return False
rc, _out, _err = _run([binary, "-m", "pip", "--version"])
return rc == 0
def _detect_pep668(binary: str) -> bool:
"""True when ``<binary>``'s install location is PEP-668 externally-managed.
Looks for ``EXTERNALLY-MANAGED`` next to the stdlib (the marker file
Debian/Ubuntu drop in to gate naive ``pip install``).
"""
if not shutil.which(binary):
return False
code = (
"import sys, os;"
"stdlib = os.path.dirname(os.__file__);"
"marker = os.path.join(stdlib, 'EXTERNALLY-MANAGED');"
"print('yes' if os.path.exists(marker) else 'no')"
)
rc, out, _err = _run([binary, "-c", code])
return rc == 0 and out.strip() == "yes"
def _pip_python_version() -> Optional[str]:
"""If ``pip`` is on PATH, return the Python version it's bound to.
``pip --version`` output looks like::
pip 24.0 from /usr/lib/python3/dist-packages/pip (python 3.12)
Returns the parenthesised version (e.g. ``"3.12"``) or None.
"""
if not shutil.which("pip"):
return None
rc, out, _err = _run(["pip", "--version"])
if rc != 0 or not out:
return None
# Parse trailing "(python X.Y)".
if "(python " in out and out.endswith(")"):
try:
tail = out.rsplit("(python ", 1)[1]
return tail[:-1].strip()
except (IndexError, AttributeError):
return None
return None
def _build_probe_line() -> str:
"""Build the one-liner. Returns "" when nothing notable is detected.
Emit only when SOMETHING is off the goal is to save the model from
hitting an avoidable wall, not to narrate a healthy environment.
"""
# Bail out if a remote terminal backend is configured; the host's
# Python state isn't where the agent's tools run.
backend = (os.getenv("TERMINAL_ENV") or "local").strip().lower()
if backend in _REMOTE_BACKENDS:
return ""
py3_ver = _python_version_of("python3")
py_ver = _python_version_of("python") # for systems with a `python` alias
py3_has_pip = _has_pip_module("python3") if py3_ver else False
pip_bound_to = _pip_python_version()
py3_pep668 = _detect_pep668("python3") if py3_ver else False
has_uv = shutil.which("uv") is not None
# If python3 exists, has pip, has uv (or no PEP 668), and there's no
# version mismatch between `pip` and `python3` → environment is
# clean enough to stay silent. The model can discover details by
# running commands if it cares.
mismatch = bool(pip_bound_to and py3_ver and not py3_ver.startswith(pip_bound_to))
silent_conditions = (
py3_ver is not None
and py3_has_pip
and not mismatch
and (not py3_pep668 or has_uv)
)
if silent_conditions:
return ""
# Build a compact factual summary. Keep it ONE line so it doesn't
# dominate the prompt; the model is good at parsing dense info.
bits: list[str] = []
if py3_ver:
py3_bit = f"python3={py3_ver}"
if not py3_has_pip:
py3_bit += " (no pip module)"
bits.append(py3_bit)
else:
bits.append("python3=missing")
if py_ver and py_ver != py3_ver:
bits.append(f"python={py_ver}")
elif not py_ver and py3_ver:
# Common on Debian/Ubuntu — call it out so the model doesn't
# type `python` and hit "command not found".
bits.append("python=missing (use python3)")
if pip_bound_to:
if mismatch:
bits.append(f"pip→python{pip_bound_to} (mismatch)")
elif not py3_has_pip:
# pip exists but `python3 -m pip` doesn't — the script
# works but the module path doesn't.
bits.append(f"pip→python{pip_bound_to}")
elif py3_has_pip:
# `pip` not on PATH but `python3 -m pip` works.
pass
else:
bits.append("pip=missing")
if py3_pep668:
bits.append("PEP 668=yes (use venv or uv)")
if has_uv:
bits.append("uv=installed")
if not bits:
return ""
return "Python toolchain: " + ", ".join(bits) + "."
def get_environment_probe_line(*, force_refresh: bool = False) -> str:
"""Return the cached probe line (building it on first call).
Returns "" when the environment is clean the system prompt
assembler should drop the section in that case rather than
emit an empty heading.
``force_refresh`` is for tests; real callers should never need it.
"""
global _CACHED_LINE
if force_refresh:
with _CACHE_LOCK:
_CACHED_LINE = None
if _CACHED_LINE is not None:
return _CACHED_LINE
with _CACHE_LOCK:
if _CACHED_LINE is not None: # raced
return _CACHED_LINE
try:
line = _build_probe_line()
except Exception as exc: # never let probe failure block prompt build
logger.debug("env_probe failed: %s", exc)
line = ""
_CACHED_LINE = line
return line
def _reset_cache_for_tests() -> None:
"""Test helper — clear the cache between probe scenarios."""
global _CACHED_LINE
with _CACHE_LOCK:
_CACHED_LINE = None
+43 -1
View File
@@ -524,8 +524,50 @@ class BaseEnvironment(ABC):
# U+FFFD substitution rather than clobbering the whole buffer.
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
def _drain_iterable(stream):
# Fallback path: ``stream`` is not backed by a real OS file
# descriptor (no usable ``fileno()``). This covers in-memory
# ProcessHandle adapters that expose stdout as a plain iterator of
# already-collected output (the legacy ``for line in proc.stdout``
# contract) rather than a live pipe. Iterate it to EOF. Without
# this, the drain thread would raise an unhandled exception and die
# silently, losing all of the process's output.
try:
for piece in stream:
if piece is None:
continue
if isinstance(piece, bytes):
output_chunks.append(decoder.decode(piece))
else:
output_chunks.append(str(piece))
except Exception:
pass
finally:
try:
tail = decoder.decode(b"", final=True)
if tail:
output_chunks.append(tail)
except Exception:
pass
def _drain():
fd = proc.stdout.fileno()
# Resolve a real OS file descriptor up front. Real subprocesses and
# the SDK ``_ThreadedProcessHandle`` (os.pipe-backed) both return an
# integer fd here. Mocks / iterator-style stdout streams either lack
# ``fileno()`` entirely or return a non-integer — in that case fall
# back to draining the stream as an iterable instead of crashing the
# thread (issue: 'list_iterator' object has no attribute 'fileno').
stream = proc.stdout
if stream is None:
return
fileno = getattr(stream, "fileno", None)
try:
fd = fileno() if callable(fileno) else None
except Exception:
fd = None
if not isinstance(fd, int) or fd < 0:
_drain_iterable(stream)
return
# 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.
+458 -40
View File
@@ -12,6 +12,7 @@ import shutil
import subprocess
import sys
import uuid
from pathlib import Path
from typing import Optional
from tools.environments.base import BaseEnvironment, _popen_bash
@@ -98,6 +99,167 @@ def _load_hermes_env_vars() -> dict[str, str]:
return {}
# Docker label values must match [a-zA-Z0-9_.-] and stay ≤63 chars to round-trip
# safely through `docker ps --filter label=key=value`. Profile and task names
# can technically contain other characters; sanitize defensively.
_LABEL_VALUE_OK_RE = re.compile(r"[^A-Za-z0-9_.-]")
def _sanitize_label_value(value: str) -> str:
"""Coerce *value* into a Docker label-safe form (alnum + ``_.-``, ≤63 chars).
Empty or all-invalid inputs collapse to ``"unknown"`` so the resulting
label is always queryable. Used at container-create time; never round-trip
a sanitized value back into application logic.
"""
if not isinstance(value, str) or not value:
return "unknown"
cleaned = _LABEL_VALUE_OK_RE.sub("_", value)
cleaned = cleaned[:63] or "unknown"
return cleaned
def _get_active_profile_name() -> str:
"""Return the active Hermes profile name, or ``"default"`` on any error.
Resolved at container-create time so a single container is permanently
tagged with the profile that created it. Profile switches inside the
same process don't retroactively relabel running containers.
"""
try:
from hermes_cli.profiles import get_active_profile_name
return get_active_profile_name() or "default"
except Exception:
return "default"
def reap_orphan_containers(
*,
max_age_seconds: int = 600,
profile_filter: str | None = None,
docker_exe: str | None = None,
) -> int:
"""Remove stale hermes-tagged containers left behind by prior processes.
Targets containers that match all of:
* ``label=hermes-agent=1`` (created by this codebase)
* ``status=exited`` (running containers are NEVER reaped they may
belong to a sibling Hermes process whose reuse path will pick them
up; killing them would crash the sibling mid-command)
* (optional) ``label=hermes-profile=<profile_filter>`` (sweep only the
caller's profile by default; a hermes process in profile A must not
tear down profile B's containers)
* ``State.FinishedAt`` older than *max_age_seconds* ago (so a sibling
process that just exited and is about to be replaced doesn't get
its container yanked out from under it)
Returns the number of containers removed. Best-effort: any failure
(docker daemon unreachable, slow inspect, parse error) is logged at
debug level and the function returns whatever it managed before the
failure. Safe to call repeatedly; idempotent.
Issue #20561 — this is the safety net for SIGKILL / OOM / crashed
terminal exits that bypass the ``atexit`` cleanup hook. Without it,
even with the cleanup-fix in the prior commit, a hard-killed Hermes
process leaves its container behind permanently because there's no
subsequent Hermes process scheduled to reuse that exact (task, profile)
pair.
"""
docker = docker_exe or find_docker() or "docker"
filters = ["--filter", "label=hermes-agent=1", "--filter", "status=exited"]
if profile_filter:
filters.extend(["--filter", f"label=hermes-profile={_sanitize_label_value(profile_filter)}"])
try:
listing = subprocess.run(
[docker, "ps", "-a", *filters, "--format", "{{.ID}}"],
capture_output=True, text=True, timeout=15, check=False,
)
except (subprocess.TimeoutExpired, OSError) as e:
logger.debug("orphan reaper docker ps failed: %s", e)
return 0
if listing.returncode != 0:
logger.debug(
"orphan reaper docker ps returned %d: %s",
listing.returncode, listing.stderr.strip(),
)
return 0
candidate_ids = [ln.strip() for ln in listing.stdout.splitlines() if ln.strip()]
if not candidate_ids:
return 0
# Inspect each candidate to get FinishedAt; reap only those exited
# long enough ago. Doing this per-container (rather than bulk inspect)
# keeps the failure blast radius to one container at a time.
import datetime
now = datetime.datetime.now(datetime.timezone.utc)
removed = 0
for cid in candidate_ids:
finished_at = _container_finished_at(docker, cid)
if finished_at is None:
# Couldn't determine age — be conservative and leave it alone.
continue
age = (now - finished_at).total_seconds()
if age < max_age_seconds:
continue
try:
result = subprocess.run(
[docker, "rm", "-f", cid],
capture_output=True, text=True, timeout=30,
)
if result.returncode == 0:
removed += 1
logger.info(
"Reaped orphan container %s (exited %d seconds ago)",
cid[:12], int(age),
)
else:
logger.debug(
"docker rm -f %s failed: %s",
cid[:12], result.stderr.strip(),
)
except (subprocess.TimeoutExpired, OSError) as e:
logger.debug("orphan reaper docker rm %s failed: %s", cid[:12], e)
return removed
def _container_finished_at(docker_exe: str, container_id: str):
"""Parse ``docker inspect`` FinishedAt for *container_id*.
Returns a timezone-aware datetime, or ``None`` if the field is missing,
unparseable, or the zero-value ``0001-01-01T00:00:00Z`` Docker emits
for never-finished containers. ``None`` means "don't reap" the caller
leaves the container alone.
"""
try:
result = subprocess.run(
[docker_exe, "inspect", "--format", "{{.State.FinishedAt}}", container_id],
capture_output=True, text=True, timeout=10, check=False,
)
except (subprocess.TimeoutExpired, OSError) as e:
logger.debug("orphan reaper docker inspect %s failed: %s", container_id[:12], e)
return None
if result.returncode != 0:
return None
raw = result.stdout.strip()
if not raw or raw.startswith("0001-01-01"):
return None
# Docker emits RFC3339 with nanoseconds (e.g. "2026-05-28T13:45:00.123456789Z").
# Python's fromisoformat handles microseconds but not nanoseconds; trim.
import re as _re
raw = _re.sub(r"(\.\d{6})\d+", r"\1", raw)
raw = raw.replace("Z", "+00:00")
try:
import datetime
return datetime.datetime.fromisoformat(raw)
except ValueError as e:
logger.debug("could not parse FinishedAt %r for %s: %s", raw, container_id[:12], e)
return None
def find_docker() -> Optional[str]:
"""Locate the docker (or podman) CLI binary.
@@ -304,15 +466,18 @@ class DockerEnvironment(BaseEnvironment):
auto_mount_cwd: bool = False,
run_as_host_user: bool = False,
extra_args: list = None,
persist_across_processes: bool = True,
):
if cwd == "~":
cwd = "/root"
super().__init__(cwd=cwd, timeout=timeout)
self._persistent = persistent_filesystem
self._persist_across_processes = persist_across_processes
self._task_id = task_id
self._forward_env = _normalize_forward_env_names(forward_env)
self._env = _normalize_env_dict(env)
self._container_id: Optional[str] = None
self._labels: dict[str, str] = {}
logger.info(f"DockerEnvironment volumes: {volumes}")
# Ensure volumes is a list (config.yaml could be malformed)
if volumes is not None and not isinstance(volumes, list):
@@ -413,6 +578,22 @@ class DockerEnvironment(BaseEnvironment):
)
for mount_entry in get_credential_file_mounts():
src = Path(mount_entry["host_path"])
if src.is_dir():
# Docker-in-Docker: Docker auto-created the source path as
# a directory when it didn't exist on the host. Mounting a
# directory over a file destination causes exit 125.
logger.warning(
"Docker: skipping credential mount — source is a directory "
"(likely Docker-in-Docker auto-creation): %s",
src,
)
continue
if not src.is_file():
logger.warning(
"Docker: skipping credential mount — source not found: %s", src,
)
continue
volume_args.extend([
"-v",
f"{mount_entry['host_path']}:{mount_entry['container_path']}:ro",
@@ -426,6 +607,13 @@ class DockerEnvironment(BaseEnvironment):
# Mount skill directories (local + external) so skill
# scripts/templates are available inside the container.
for skills_mount in get_skills_directory_mount():
src = Path(skills_mount["host_path"])
if not src.is_dir():
logger.warning(
"Docker: skipping skills mount — source is not a directory: %s",
src,
)
continue
volume_args.extend([
"-v",
f"{skills_mount['host_path']}:{skills_mount['container_path']}:ro",
@@ -441,6 +629,13 @@ class DockerEnvironment(BaseEnvironment):
# cached media from inside the container. Read-only — the
# container reads these but the host gateway manages writes.
for cache_mount in get_cache_directory_mounts():
src = Path(cache_mount["host_path"])
if not src.is_dir():
logger.warning(
"Docker: skipping cache mount — source is not a directory: %s",
src,
)
continue
volume_args.extend([
"-v",
f"{cache_mount['host_path']}:{cache_mount['container_path']}:ro",
@@ -506,25 +701,88 @@ class DockerEnvironment(BaseEnvironment):
# Start the container directly via `docker run -d`.
container_name = f"hermes-{uuid.uuid4().hex[:8]}"
run_cmd = [
self._docker_exe, "run", "-d",
"--init", # tini/catatonit as PID 1 — reaps zombie children
"--name", container_name,
"-w", cwd,
*all_run_args,
image,
"sleep", "infinity", # no fixed lifetime — idle reaper handles cleanup
# Labels make hermes-created containers identifiable to:
# * the orphan reaper (`hermes-agent=1` for the global sweep filter)
# * future cross-process reuse (`hermes-task-id`, `hermes-profile`)
# * operators running `docker ps --filter label=hermes-agent=1`
# Values are limited to the safe character set defined by
# _sanitize_label_value(); the active Hermes profile is captured at
# container-start time and never changes for the container's lifetime.
profile_name = _sanitize_label_value(_get_active_profile_name())
task_label = _sanitize_label_value(task_id)
label_args = [
"--label", "hermes-agent=1",
"--label", f"hermes-task-id={task_label}",
"--label", f"hermes-profile={profile_name}",
]
logger.debug(f"Starting container: {' '.join(run_cmd)}")
result = subprocess.run(
run_cmd,
capture_output=True,
text=True,
timeout=120, # image pull may take a while
check=True,
)
self._container_id = result.stdout.strip()
logger.info(f"Started container {container_name} ({self._container_id[:12]})")
self._labels = {
"hermes-agent": "1",
"hermes-task-id": task_label,
"hermes-profile": profile_name,
}
# Cross-process container reuse (issue #20561 — docs claim "ONE long-lived
# container shared across sessions"). If a prior Hermes process
# already started a container for this (task_id, profile) and it
# still exists, attach to it instead of starting a fresh one. This
# restores the documented contract; opt out via
# ``terminal.docker_persist_across_processes: false``.
#
# Reuse matches on labels only — we deliberately do NOT compare image
# / mounts / resources. Operators who need a fresh container after
# changing those settings should set ``docker_persist_across_processes:
# false`` (or run ``docker rm -f`` against the labeled container) to
# force a clean start.
reused = False
if persist_across_processes:
existing = self._find_reusable_container(task_label, profile_name)
if existing is not None:
container_id, state = existing
self._container_id = container_id
if state != "running":
try:
subprocess.run(
[self._docker_exe, "start", container_id],
capture_output=True,
text=True,
timeout=30,
check=True,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
logger.warning(
"Failed to start existing container %s (state=%s): "
"%s — falling back to a fresh container.",
container_id[:12], state, e,
)
self._container_id = None
if self._container_id:
logger.info(
"Reusing container %s (task=%s, profile=%s, prior state=%s)",
container_id[:12], task_label, profile_name, state,
)
reused = True
if not reused:
run_cmd = [
self._docker_exe, "run", "-d",
"--init", # tini/catatonit as PID 1 — reaps zombie children
"--name", container_name,
*label_args,
"-w", cwd,
*all_run_args,
image,
"sleep", "infinity", # no fixed lifetime — idle reaper handles cleanup
]
logger.debug(f"Starting container: {' '.join(run_cmd)}")
result = subprocess.run(
run_cmd,
capture_output=True,
text=True,
timeout=120, # image pull may take a while
check=True,
)
self._container_id = result.stdout.strip()
logger.info(f"Started container {container_name} ({self._container_id[:12]})")
# Build the init-time env forwarding args (used only by init_session
# to inject host env vars into the snapshot; subsequent commands get
@@ -629,31 +887,191 @@ class DockerEnvironment(BaseEnvironment):
logger.debug("Docker --storage-opt support: %s", _storage_opt_ok)
return _storage_opt_ok
def cleanup(self):
"""Stop and remove the container. Bind-mount dirs persist if persistent=True."""
if self._container_id:
try:
# Stop in background so cleanup doesn't block
stop_cmd = (
f"(timeout 60 {self._docker_exe} stop {self._container_id} || "
f"{self._docker_exe} rm -f {self._container_id}) >/dev/null 2>&1 &"
)
subprocess.Popen(stop_cmd, shell=True)
except Exception as e:
logger.warning("Failed to stop container %s: %s", self._container_id, e)
def _find_reusable_container(self, task_label: str, profile_label: str) -> Optional[tuple[str, str]]:
"""Look for an existing container labeled for this (task, profile).
Returns ``(container_id, state)`` on hit, ``None`` on miss / on any
failure (including ``docker ps`` itself failing). State is one of the
values Docker reports via ``{{.State}}`` e.g. ``running``, ``exited``,
``created``, ``paused``, ``restarting``, ``dead``. The caller decides
whether the state warrants ``docker start`` before reuse.
Restricted to the docker-stored label set this class creates; never
matches containers that happened to be named ``hermes-*`` but were
started by some other tool.
"""
try:
result = subprocess.run(
[
self._docker_exe, "ps", "-a",
"--filter", "label=hermes-agent=1",
"--filter", f"label=hermes-task-id={task_label}",
"--filter", f"label=hermes-profile={profile_label}",
"--format", "{{.ID}}\t{{.State}}",
],
capture_output=True,
text=True,
timeout=10,
check=False,
)
except (subprocess.TimeoutExpired, OSError) as e:
logger.debug("docker ps probe failed: %s — will start a fresh container", e)
return None
if result.returncode != 0:
logger.debug(
"docker ps probe returned %d: %s — will start a fresh container",
result.returncode, result.stderr.strip(),
)
return None
lines = [ln.strip() for ln in result.stdout.splitlines() if ln.strip()]
if not lines:
return None
# Multiple matches are unusual (one (task, profile) should produce one
# container) but can happen if a previous Hermes process crashed
# mid-cleanup. Prefer a running one if present; otherwise pick the
# first listed. Stale duplicates get reaped by the orphan-reaper in a
# follow-up commit; we don't try to be heroic about them here.
running = None
first = None
for ln in lines:
parts = ln.split("\t", 1)
if len(parts) != 2:
continue
cid, state = parts[0], parts[1].lower()
if first is None:
first = (cid, state)
if state == "running" and running is None:
running = (cid, state)
return running or first
def cleanup(self, *, force_remove: bool = False):
"""Tear down the container according to persist mode and *force_remove*.
Persist-mode (``persist_across_processes=True``, the default) leaves the
container **running** untouched. The docs promise "ONE long-lived
container shared across sessions" and stopping it on every Hermes exit
breaks that promise:
* Background processes inside the container (``npm run dev``, watchers,
long-running pytest) get killed every time the user runs ``/quit``.
* Every reuse requires ``docker start`` + waiting for the container to
come back up, adding 12s to the first tool call of the new session.
* The user-visible difference between "ONE long-lived container" and
"a new container that happens to share state" is exactly this:
processes survive in the former, die in the latter.
Resource reclamation for the persist-mode case lives in the
``reap_orphan_containers()`` path (see issue #20561 commit 3): if no
Hermes process touches a labeled container for ``2 × lifetime_seconds``
it gets ``docker rm -f``'d at the next Hermes startup. That covers the
SIGKILL / OOM / abandoned-laptop cases without us needing to stop the
container on every graceful exit.
Opt-out mode (``persist_across_processes=False``) still does
``docker stop`` + ``docker rm -f`` on every cleanup, matching the
pre-PR behavior for users who explicitly want per-process isolation.
``force_remove=True`` overrides persist mode and always tears the
container down (``docker stop`` + ``docker rm -f``). This is the
explicit-teardown path for ``/reset``, ``cleanup_vm(task_id)``-driven
resets, or any caller that wants a guaranteed fresh container on next
``DockerEnvironment(task_id=...)``. No current caller passes
``force_remove=True``; the parameter is here so the explicit-teardown
semantics can be wired up later without changing this method's
signature.
Cleanup runs on a daemon thread with bounded ``subprocess.run`` calls
(not the racy ``Popen(... &)`` pattern from before PR #33645). The
atexit hook in ``tools/terminal_tool.py`` waits up to 15s for the
thread to finish before the interpreter exits, so ``docker stop`` /
``docker rm`` actually completes when we do trigger it.
"""
container_id = self._container_id
if not container_id:
# Still drop the bind-mount dirs if any were allocated and we're
# NOT in persist mode (persist mode preserves them).
if not self._persistent:
# Also schedule removal (stop only leaves it as stopped)
try:
subprocess.Popen(
f"sleep 3 && {self._docker_exe} rm -f {self._container_id} >/dev/null 2>&1 &",
shell=True,
)
except Exception:
pass
self._container_id = None
for d in (self._workspace_dir, self._home_dir):
if d:
shutil.rmtree(d, ignore_errors=True)
return
if not self._persistent:
# Decide what to actually do. Three cases:
#
# force_remove=True → stop + rm (explicit teardown)
# persist_across_processes=True → no-op (leave container running)
# persist_across_processes=False → stop + rm (per-process isolation)
#
# The persist-mode no-op is the issue-#20561 contract: the container
# outlives Hermes processes, processes inside it stay alive, and
# reuse on next startup is instant.
if force_remove:
should_stop = True
should_remove = True
elif self._persist_across_processes:
# No-op for the container. Drop the in-process handle so a fresh
# __init__ will re-probe via labels (and find the running
# container) instead of trying to reuse a stale Python reference.
self._container_id = None
return
else:
should_stop = True
should_remove = True
# Capture state needed by the worker before we null out the attrs —
# the worker thread can outlive ``self``.
docker_exe = self._docker_exe
log_id = container_id[:12]
def _do_cleanup() -> None:
if should_stop:
try:
subprocess.run(
[docker_exe, "stop", "-t", "10", container_id],
capture_output=True, timeout=30,
)
except (subprocess.TimeoutExpired, OSError) as e:
logger.warning("docker stop %s timed out / failed: %s", log_id, e)
if should_remove:
try:
subprocess.run(
[docker_exe, "rm", "-f", container_id],
capture_output=True, timeout=30,
)
except (subprocess.TimeoutExpired, OSError) as e:
logger.warning("docker rm -f %s failed: %s", log_id, e)
# Daemon thread: doesn't block interpreter exit (atexit returns
# promptly), but unlike the old ``Popen(... &)`` shell trick the
# Python-level join semantics let the thread actually run to
# completion if the interpreter is still alive. atexit registers
# ``_atexit_cleanup`` in terminal_tool.py which waits up to ~60s for
# outstanding cleanups, so most exits complete the work cleanly.
import threading
t = threading.Thread(target=_do_cleanup, daemon=True, name=f"hermes-cleanup-{log_id}")
t.start()
self._cleanup_thread = t
self._container_id = None
# Bind-mount dir teardown only runs when we actually removed the
# container (the dirs are the container's filesystem state; keeping
# them around with no container would orphan the data on disk).
if should_remove and not self._persistent:
for d in (self._workspace_dir, self._home_dir):
if d:
shutil.rmtree(d, ignore_errors=True)
def wait_for_cleanup(self, timeout: float = 30.0) -> bool:
"""Block up to *timeout* seconds for the cleanup worker thread.
Returns ``True`` if the thread finished (or no thread was started),
``False`` on timeout. The atexit hook in terminal_tool.py calls this
on every active environment so docker stop/rm actually completes
before the Python process exits without this, ``hermes /quit``
races the interpreter shutdown and leaves stopped containers behind.
"""
thread = getattr(self, "_cleanup_thread", None)
if thread is None or not thread.is_alive():
return True
thread.join(timeout=timeout)
return not thread.is_alive()
+24 -1
View File
@@ -75,6 +75,27 @@ def _resolve_safe_cwd(cwd: str) -> str:
# Hermes-internal env vars that should NOT leak into terminal subprocesses.
_HERMES_PROVIDER_ENV_FORCE_PREFIX = "_HERMES_FORCE_"
# Hermes-managed AWS *inference* credentials for ``auth_type="aws_sdk"``
# providers (Bedrock). Scoped DELIBERATELY NARROW: this lists only the
# Bedrock-specific bearer token, which is a Hermes inference secret exactly
# analogous to ``OPENAI_API_KEY`` — nobody drives the ``aws``/``terraform``/
# ``boto3`` toolchain off it, so stripping it from terminal/execute_code
# subprocesses costs no user capability.
#
# The GENERAL AWS credential chain (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY,
# AWS_SESSION_TOKEN, AWS_PROFILE, and the config/role pointers) is INTENTIONALLY
# left inheritable. Per SECURITY.md §3.2 the local terminal is the user's
# trusted operator shell; the agent having the same general AWS access the
# user's own shell has is the intended posture, not a leak. Hard-blocklisting
# those vars would (a) regress every user who runs aws/terraform/cdk/boto3 in
# the agent terminal — not just Bedrock users, since the registry is iterated
# unconditionally — and (b) be unrecoverable, because env_passthrough.py
# refuses to re-allow anything in this blocklist (GHSA-rhgp-j443-p4rf). See
# issue #32314 discussion.
_AWS_SDK_CREDENTIAL_ENV_VARS = frozenset({
"AWS_BEARER_TOKEN_BEDROCK",
})
def _build_provider_env_blocklist() -> frozenset:
"""Derive the blocklist from provider, tool, and gateway config."""
@@ -84,6 +105,8 @@ def _build_provider_env_blocklist() -> frozenset:
from hermes_cli.auth import PROVIDER_REGISTRY
for pconfig in PROVIDER_REGISTRY.values():
blocked.update(pconfig.api_key_env_vars)
if pconfig.auth_type == "aws_sdk":
blocked.update(_AWS_SDK_CREDENTIAL_ENV_VARS)
if pconfig.base_url_env_var:
blocked.add(pconfig.base_url_env_var)
except ImportError:
@@ -316,7 +339,7 @@ def _make_run_env(env: dict) -> dict:
# Inject ContextVar-based session vars into subprocess env.
# ContextVars don't propagate to child processes, so we bridge them here.
try:
from gateway.session_context import get_session_env, _UNSET, _VAR_MAP
from gateway.session_context import _UNSET, _VAR_MAP
for var_name, var in _VAR_MAP.items():
value = var.get()
if value is not _UNSET and value:
-12
View File
@@ -37,7 +37,6 @@ from tools.binary_extensions import BINARY_EXTENSIONS
from agent.file_safety import (
build_write_denied_paths,
build_write_denied_prefixes,
get_safe_write_root as _shared_get_safe_write_root,
is_write_denied as _shared_is_write_denied,
)
@@ -114,17 +113,6 @@ def _normalize_line_endings(text: str, target: str) -> str:
return text
def _get_safe_write_root() -> Optional[str]:
"""Return the resolved HERMES_WRITE_SAFE_ROOT path, or None if unset.
When set, all write_file/patch operations are constrained to this
directory tree. Writes outside it are denied even if the target is
not on the static deny list. Opt-in hardening for gateway/messaging
deployments that should only touch a workspace checkout.
"""
return _shared_get_safe_write_root()
def _is_write_denied(path: str) -> bool:
"""Return True if path is on the write deny list."""
return _shared_is_write_denied(path)
+58 -1
View File
@@ -113,8 +113,29 @@ def fuzzy_find_and_replace(content: str, old_string: str, new_string: str,
# old_string/new_string — e.g. LLM used 2-space indent but the
# file is 4-space. Shift new_string by the indentation delta so
# the replacement matches the file's actual indent pattern.
# LLMs frequently serialize tabs / carriage returns in JSON
# tool-call arguments as the two-character sequences ``\t`` and
# ``\r`` (backslash + letter) instead of the real control bytes.
# If we write new_string verbatim, the file ends up with literal
# backslash sequences where the surrounding code uses real tabs.
#
# Strategy: only unescape when the matched region of the file
# *actually contains* the corresponding real control character.
# That mirrors the region-based heuristic in
# ``_detect_escape_drift`` and keeps legitimate writes of the
# literal two-character string ``"\t"`` (e.g. patching Python
# source that contains a tab string literal in source text)
# untouched — those files have a backslash+t in the matched
# region, not a real tab, so we leave new_string alone.
#
# ``\n`` is intentionally excluded: newlines serialize correctly
# through JSON, and rewriting backslash-n would mangle escape
# sequences in source code constants far more often than help.
effective_new = _maybe_unescape_new_string(
new_string, content, matches,
)
new_content = _apply_replacements(
content, matches, new_string,
content, matches, effective_new,
old_string=old_string if strategy_name != "exact" else None,
)
return new_content, len(matches), strategy_name, None
@@ -247,6 +268,42 @@ def _reindent_replacement(file_region: str, old_string: str, new_string: str) ->
return "\n".join(out_lines)
def _maybe_unescape_new_string(new_string: str,
content: str,
matches: List[Tuple[int, int]]) -> str:
"""Conditionally unescape ``\\t``/``\\r`` in new_string.
LLMs frequently send the two-character sequences ``\\t`` (backslash + t)
and ``\\r`` (backslash + r) inside JSON tool-call arguments where they
meant a real tab or carriage-return byte. Writing the string verbatim
corrupts tab-indented files with literal backslash-letter pairs.
The unescape is only applied per-sequence when the *matched region of
the file* actually contains the corresponding control character that
is, we only convert ``\\t`` -> tab when the file region we're replacing
contains a real tab byte. Files that legitimately contain the literal
two-character string ``"\\t"`` (e.g. a Python source line that defines
``sep = "\\t"``) get a backslash+t in the matched region instead of a
tab, so we leave new_string alone.
``\\n`` is intentionally excluded: newlines serialize correctly through
JSON and rewriting backslash-n would corrupt escape sequences in
string literals far more often than it would help.
"""
# Cheap pre-check — bail out unless new_string actually contains one of
# the suspect sequences. Keeps the common case free.
if "\\t" not in new_string and "\\r" not in new_string:
return new_string
matched_regions = "".join(content[start:end] for start, end in matches)
out = new_string
if "\\t" in out and "\t" in matched_regions:
out = out.replace("\\t", "\t")
if "\\r" in out and "\r" in matched_regions:
out = out.replace("\\r", "\r")
return out
def _apply_replacements(content: str, matches: List[Tuple[int, int]],
new_string: str, old_string: Optional[str] = None) -> str:
"""
+64
View File
@@ -66,6 +66,7 @@ from tools.managed_tool_gateway import resolve_managed_tool_gateway
from tools.tool_backend_helpers import (
fal_key_is_configured,
managed_nous_tools_enabled,
nous_tool_gateway_unavailable_message,
prefers_gateway,
)
@@ -317,6 +318,54 @@ FAL_MODELS: Dict[str, Dict[str, Any]] = {
},
"upscale": False,
},
# Krea 2 — Krea's first foundation image model, day-0 partner launch on
# fal (2026-05-27). Same model family as our direct ``plugins/image_gen/krea``
# backend, exposed here for users who prefer to bill through their
# existing FAL key / Nous Portal subscription rather than register
# directly with Krea. Both variants share the same parameter schema —
# only model id, price, and recommended use case differ.
"fal-ai/krea/v2/medium/text-to-image": {
"display": "Krea 2 Medium",
"speed": "~15-25s",
"strengths": "Illustration, anime, painting, expressive/artistic styles",
"price": "$0.030 (text) / $0.035 (style refs)",
"size_style": "aspect_ratio",
# Krea natively accepts 1:1, 4:3, 3:2, 16:9, 2.35:1, 4:5, 2:3, 9:16 —
# we map our 3 abstract ratios to the closest match.
"sizes": {
"landscape": "16:9",
"square": "1:1",
"portrait": "9:16",
},
"defaults": {
"creativity": "medium",
},
"supports": {
"prompt", "aspect_ratio", "creativity", "seed",
"image_style_references",
},
"upscale": False,
},
"fal-ai/krea/v2/large/text-to-image": {
"display": "Krea 2 Large",
"speed": "~25-60s",
"strengths": "Photorealism, raw textured looks (motion blur, grain, film)",
"price": "$0.060 (text) / $0.065 (style refs)",
"size_style": "aspect_ratio",
"sizes": {
"landscape": "16:9",
"square": "1:1",
"portrait": "9:16",
},
"defaults": {
"creativity": "medium",
},
"supports": {
"prompt", "aspect_ratio", "creativity", "seed",
"image_style_references",
},
"upscale": False,
},
}
# Default model is the fastest reasonable option. Kept cheap and sub-1s.
@@ -404,12 +453,22 @@ def _submit_fal_request(model: str, arguments: Dict[str, Any]):
# of a raw HTTP error from httpx.
status = _extract_http_status(exc)
if status is not None and 400 <= status < 500:
gateway_message = ""
if status in {401, 402, 403}:
gateway_message = (
"\n\n"
+ nous_tool_gateway_unavailable_message(
"managed FAL image generation",
force_fresh=True,
)
)
raise ValueError(
f"Nous Subscription gateway rejected model '{model}' "
f"(HTTP {status}). This model may not yet be enabled on "
f"the Nous Portal's FAL proxy. Either:\n"
f" • Set FAL_KEY in your environment to use FAL.ai directly, or\n"
f" • Pick a different model via `hermes tools` → Image Generation."
f"{gateway_message}"
) from exc
raise
@@ -719,6 +778,11 @@ def _build_no_backend_setup_message() -> str:
)
else:
lines.append(" - FAL_KEY environment variable is not set")
gateway_message = nous_tool_gateway_unavailable_message(
"managed FAL image generation",
)
if gateway_message:
lines.append(f" - {gateway_message}")
lines.append("")
lines.append("To enable image generation, do one of:")
lines.append(
+84
View File
@@ -176,6 +176,90 @@ def _connect(board: Optional[str] = None):
return kb, kb.connect(board=board)
# ---------------------------------------------------------------------------
# Runtime-activity → board-heartbeat bridge (#31752)
# ---------------------------------------------------------------------------
# When the agent ticks ``_touch_activity`` during normal work (between
# tool calls, mid-stream chunks, etc.), we want the kanban board's
# ``last_heartbeat_at`` columns to reflect that liveness so the dispatcher
# watchdog (which reads ``tasks.last_heartbeat_at``, not the agent's
# in-process timestamp) doesn't reclaim an actively-running worker as
# stale. The model is not required to call the explicit ``kanban_heartbeat``
# tool for this to work — that tool stays available for workers that want
# to attach a note or pre-emptively extend a claim across a known-long op.
#
# Constraints:
# - Best-effort: never raise. The agent loop must not care if the bridge
# fails (board missing, DB locked, etc.).
# - Rate-limited to one DB write per 60s per-process; runtime activity
# can tick on every chunk/tool result and we don't need that resolution.
# - No-op outside dispatcher-spawned worker context (no ``HERMES_KANBAN_TASK``).
# - No durable note on these auto-heartbeats; that's reserved for the
# explicit tool which carries a model-supplied note.
_AUTO_HEARTBEAT_MIN_INTERVAL_SECONDS = 60.0
_auto_heartbeat_last_attempt: float = 0.0
def heartbeat_current_worker_from_env() -> bool:
"""Best-effort: extend the kanban claim + bump board heartbeat for the
current dispatcher-spawned worker, using identity from env vars.
Returns True if a write was attempted (whether or not it succeeded);
False if the call was skipped (not a kanban worker, rate-limited, or
swallowed exception). The boolean is informational callers should
not branch on it.
Identity comes from:
* ``HERMES_KANBAN_TASK`` task id (required; absence means no-op)
* ``HERMES_KANBAN_RUN_ID`` pins the run row so we don't heartbeat
a stale run that may have already been reclaimed
* ``HERMES_KANBAN_CLAIM_LOCK`` claim lock for ``heartbeat_claim``;
falls back to the default ``_claimer_id()`` for locally-driven
workers that never went through the dispatcher path
Rate-limited via the module-level ``_auto_heartbeat_last_attempt``
timestamp (monotonic clock); not thread-safe in the strict sense, but
the worst case is one extra DB write per race, which is harmless.
"""
global _auto_heartbeat_last_attempt
tid = os.environ.get("HERMES_KANBAN_TASK")
if not tid:
return False
import time as _time
now = _time.monotonic()
if (now - _auto_heartbeat_last_attempt) < _AUTO_HEARTBEAT_MIN_INTERVAL_SECONDS:
return False
_auto_heartbeat_last_attempt = now
try:
kb, conn = _connect()
try:
claim_lock = os.environ.get("HERMES_KANBAN_CLAIM_LOCK")
try:
kb.heartbeat_claim(conn, tid, claimer=claim_lock)
except Exception:
logger.debug("auto-heartbeat: heartbeat_claim failed", exc_info=True)
run_id_raw = os.environ.get("HERMES_KANBAN_RUN_ID")
run_id: Optional[int]
try:
run_id = int(run_id_raw) if run_id_raw else None
except (TypeError, ValueError):
run_id = None
try:
kb.heartbeat_worker(conn, tid, note=None, expected_run_id=run_id)
except Exception:
logger.debug("auto-heartbeat: heartbeat_worker failed", exc_info=True)
finally:
try:
conn.close()
except Exception:
pass
return True
except Exception:
logger.debug("auto-heartbeat: bridge failed", exc_info=True)
return False
def _ok(**fields: Any) -> str:
return json.dumps({"ok": True, **fields})
+6 -5
View File
@@ -97,15 +97,16 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = {
# (see comment at top of [project.dependencies]). When bumping, update
# both this map AND the corresponding extra in pyproject.toml.
#
# NOTE: tts.mistral / stt.mistral entries are intentionally absent —
# the `mistralai` PyPI project is quarantined as of 2026-05-12 (Mini
# Shai-Hulud worm). Re-add when PyPI restores a clean release; see
# comment in pyproject.toml above the (removed) `mistral` extra for
# the full restoration checklist.
# mistralai pin tracks the `mistral` extra in pyproject.toml. PyPI
# quarantined the project 2026-05-12 (malicious 2.4.6, Mini Shai-Hulud);
# 2.4.6 was removed and clean releases resumed (2.4.7, 2.4.8). Voxtral
# STT + TTS share the same SDK.
"tts.mistral": ("mistralai==2.4.8",),
"tts.edge": ("edge-tts==7.2.7",),
"tts.elevenlabs": ("elevenlabs==1.59.0",),
# ─── Speech-to-text providers ──────────────────────────────────────────
"stt.mistral": ("mistralai==2.4.8",),
"stt.faster_whisper": (
"faster-whisper==1.2.1",
"sounddevice==0.5.5",
+133 -1
View File
@@ -422,6 +422,17 @@ def _resolve_stdio_command(command: str, env: dict) -> tuple[str, dict]:
candidates = [
os.path.join(hermes_home, "node", "bin", resolved_command),
os.path.join(os.path.expanduser("~"), ".local", "bin", resolved_command),
# /usr/local/bin is the canonical install location for Node on
# Linux from-source builds, the upstream node:bookworm-slim
# image (which the Hermes Docker image copies node + npm +
# corepack from since #4977), and macOS Homebrew on Intel.
# Without this candidate, any MCP server configured with an
# env.PATH that omits /usr/local/bin (a common pattern when
# users hand-author PATH for sandboxing) fails with ENOENT
# at execvp, and a naive symlink workaround into the user's
# PATH only fails one layer deeper because npx's shebang
# re-execs /usr/bin/env node which needs the same directory.
os.path.join(os.sep, "usr", "local", "bin", resolved_command),
]
for candidate in candidates:
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
@@ -559,6 +570,78 @@ def _validate_remote_mcp_url(server_name: str, url: Any) -> str:
return stripped
def _resolve_client_cert(server_name: str, config: dict):
"""Resolve the ``client_cert`` / ``client_key`` config for mTLS.
Returns whatever ``httpx``'s ``cert=`` parameter accepts, or ``None`` when
no client certificate is configured:
- ``None`` if neither ``client_cert`` nor ``client_key`` is set.
- A single absolute path string if ``client_cert`` is a string and
``client_key`` is unset (PEM file with cert + key combined).
- A ``(cert_path, key_path)`` tuple when both are set, or when
``client_cert`` is a 2-element list/tuple.
- A ``(cert_path, key_path, password)`` tuple when ``client_cert`` is
a 3-element list/tuple the third element is the key passphrase.
User paths support ``~`` expansion. Missing files raise ``FileNotFoundError``
with a server-scoped message so the failure surfaces as a clear setup
error rather than an opaque TLS handshake error.
"""
raw_cert = config.get("client_cert")
raw_key = config.get("client_key")
if raw_cert is None and raw_key is None:
return None
def _expand(path: Any, label: str) -> str:
if not isinstance(path, str) or not path.strip():
raise ValueError(
f"MCP server '{server_name}': {label} must be a non-empty "
f"string path (got {type(path).__name__})"
)
expanded = os.path.expanduser(path.strip())
if not os.path.isfile(expanded):
raise FileNotFoundError(
f"MCP server '{server_name}': {label} not found at "
f"{expanded!r}"
)
return expanded
# Tuple/list form for client_cert — (cert, key) or (cert, key, password).
if isinstance(raw_cert, (list, tuple)):
if raw_key is not None:
raise ValueError(
f"MCP server '{server_name}': specify either client_cert as "
f"a list [cert, key] OR client_cert + client_key, not both"
)
if len(raw_cert) == 2:
cert_path = _expand(raw_cert[0], "client_cert[0]")
key_path = _expand(raw_cert[1], "client_cert[1]")
return (cert_path, key_path)
if len(raw_cert) == 3:
cert_path = _expand(raw_cert[0], "client_cert[0]")
key_path = _expand(raw_cert[1], "client_cert[1]")
password = raw_cert[2]
if not isinstance(password, str):
raise ValueError(
f"MCP server '{server_name}': client_cert[2] (key "
f"passphrase) must be a string"
)
return (cert_path, key_path, password)
raise ValueError(
f"MCP server '{server_name}': client_cert list form must have 2 "
f"or 3 elements (got {len(raw_cert)})"
)
# String form for client_cert.
cert_path = _expand(raw_cert, "client_cert")
if raw_key is not None:
key_path = _expand(raw_key, "client_key")
return (cert_path, key_path)
# Single combined PEM file (cert + key in one file).
return cert_path
def _format_connect_error(exc: BaseException) -> str:
"""Render nested MCP connection errors into an actionable short message."""
@@ -1363,6 +1446,7 @@ class MCPServerTask:
headers["mcp-protocol-version"] = LATEST_PROTOCOL_VERSION
connect_timeout = config.get("connect_timeout", _DEFAULT_CONNECT_TIMEOUT)
ssl_verify = config.get("ssl_verify", True)
client_cert = _resolve_client_cert(self.name, config)
# OAuth 2.1 PKCE: route through the central MCPOAuthManager so the
# same provider instance is reused across reconnects, pre-flow
@@ -1414,6 +1498,37 @@ class MCPServerTask:
# behind OAuth 2.1 PKCE work. Previously built but never
# forwarded — SSE OAuth would silently fail with 401s.
_sse_kwargs["auth"] = _oauth_auth
if client_cert is not None or ssl_verify is not True:
# SSE transport doesn't expose verify/cert as kwargs, so route
# them through an httpx_client_factory that wraps the SDK's
# defaults (follow_redirects=True) and adds our TLS settings.
# The SDK calls the factory with (headers, auth, timeout); we
# forward all of those and layer verify/cert on top.
import httpx as _httpx_mod
_cert_for_factory = client_cert
_verify_for_factory = ssl_verify
def _mcp_http_client_factory(
headers=None, timeout=None, auth=None,
):
kwargs: dict = {
"follow_redirects": True,
"verify": _verify_for_factory,
}
if timeout is not None:
kwargs["timeout"] = timeout
else:
kwargs["timeout"] = _httpx_mod.Timeout(30.0, read=300.0)
if headers is not None:
kwargs["headers"] = headers
if auth is not None:
kwargs["auth"] = auth
if _cert_for_factory is not None:
kwargs["cert"] = _cert_for_factory
return _httpx_mod.AsyncClient(**kwargs)
_sse_kwargs["httpx_client_factory"] = _mcp_http_client_factory
async with sse_client(**_sse_kwargs) as (read_stream, write_stream):
async with ClientSession(
read_stream, write_stream, **sampling_kwargs
@@ -1457,6 +1572,8 @@ class MCPServerTask:
client_kwargs["headers"] = headers
if _oauth_auth is not None:
client_kwargs["auth"] = _oauth_auth
if client_cert is not None:
client_kwargs["cert"] = client_cert
# Caller owns the client lifecycle — the SDK skips cleanup when
# http_client is provided, so we wrap in async-with.
@@ -1536,6 +1653,21 @@ class MCPServerTask:
"this warning.",
self.name,
)
# Validate remote URL once, up front. Raising here (rather than
# letting it blow up inside the SDK's httpx layer on every retry)
# means a typo in config.yaml fails fast with a clear error — and
# critically, no reconnect-backoff burn. (Ported from
# anomalyco/opencode#25019.)
if self._is_http():
try:
_validate_remote_mcp_url(self.name, config.get("url"))
except InvalidMcpUrlError as exc:
logger.warning("%s", exc)
self._error = exc
self._ready.set()
return
retries = 0
initial_retries = 0
backoff = 1.0
@@ -3234,7 +3366,7 @@ def register_mcp_servers(servers: Dict[str, dict]) -> List[str]:
return_exceptions=True,
)
for name, result in zip(server_names, results):
if isinstance(result, Exception):
if isinstance(result, BaseException):
command = new_servers.get(name, {}).get("command")
logger.warning(
"Failed to connect to MCP server '%s'%s: %s",
-1
View File
@@ -26,7 +26,6 @@ Design:
import json
import logging
import os
import re
import tempfile
import time
from contextlib import contextmanager
+1 -3
View File
@@ -13,7 +13,6 @@ import re
import ssl
import time
from email.utils import formatdate
from typing import Dict, Optional
from agent.redact import redact_sensitive_text
@@ -139,7 +138,7 @@ SEND_MESSAGE_SCHEMA = {
},
"message": {
"type": "string",
"description": "The message text to send. To send an image or file, include MEDIA:<local_path> for a file under a Hermes media cache or HERMES_MEDIA_ALLOW_DIRS — the platform will deliver it as a native media attachment."
"description": "The message text to send. To send an image or file, include MEDIA:<local_path> (e.g. 'MEDIA:/tmp/report.pdf') in the message — the platform will deliver it as a native media attachment."
}
},
"required": []
@@ -1270,7 +1269,6 @@ async def _send_email(extra, chat_id, message):
"""Send via SMTP (one-shot, no persistent connection needed)."""
import smtplib
from email.mime.text import MIMEText
from email.utils import formatdate
address = extra.get("address") or os.getenv("EMAIL_ADDRESS", "")
password = os.getenv("EMAIL_PASSWORD", "")
+1 -1
View File
@@ -31,7 +31,7 @@ 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
from typing import Any, Dict, List, Optional, Set, Tuple
from hermes_constants import get_hermes_home
from agent.skill_utils import is_excluded_skill_path
+10 -1
View File
@@ -36,7 +36,16 @@ from typing import List, Tuple
# Hardcoded trust configuration
# ---------------------------------------------------------------------------
TRUSTED_REPOS = {"openai/skills", "anthropics/skills", "huggingface/skills"}
TRUSTED_REPOS = {
"openai/skills",
"anthropics/skills",
"huggingface/skills",
# NVIDIA-verified skills: each entry ships a signed `skill.oms.sig`
# and a governance `skill-card.md` (sync pipeline drops anything
# missing the signature or card). Catalog details:
# https://github.com/NVIDIA/skills
"NVIDIA/skills",
}
INSTALL_POLICY = {
# safe caution dangerous
+195 -3
View File
@@ -401,6 +401,14 @@ class GitHubSource(SkillSource):
{"repo": "openai/skills", "path": "skills/.system/"},
{"repo": "anthropics/skills", "path": "skills/"},
{"repo": "huggingface/skills", "path": "skills/"},
# NVIDIA/skills: NVIDIA-verified skills for CUDA-X, AIQ, cuOpt,
# cuPyNumeric, DeepStream, NeMo, NemoClaw, etc. Each skill ships
# alongside a signed `skill.oms.sig`, an OMS-signed `skill-card.md`
# (governance card), and an `evals/` directory — synced daily from
# the NVIDIA product repos. Treated as `trusted` (see
# `tools/skills_guard.py::TRUSTED_REPOS`). Sample layout:
# https://github.com/NVIDIA/skills/tree/main/skills
{"repo": "NVIDIA/skills", "path": "skills/"},
{"repo": "garrytan/gstack", "path": ""},
]
@@ -412,6 +420,10 @@ class GitHubSource(SkillSource):
# Per-instance cache: repo -> (default_branch, tree_entries)
# Survives within a single search/install flow, avoiding redundant API calls.
self._tree_cache: Dict[str, Tuple[str, List[dict]]] = {}
# Per-repo cache of the optional skills.sh.json grouping sidecar,
# mapping skill_name -> human-readable grouping title. ``None`` means
# "fetched, no sidecar"; a missing key means "not fetched yet".
self._skillsh_groupings: Dict[str, Optional[Dict[str, str]]] = {}
# Set when GitHub returns 403 with rate limit exhausted
self._rate_limited: bool = False
@@ -550,6 +562,7 @@ class GitHubSource(SkillSource):
return []
skills: List[SkillMeta] = []
groupings = self._get_skillsh_groupings(repo)
for entry in entries:
if entry.get("type") != "dir":
continue
@@ -562,6 +575,10 @@ class GitHubSource(SkillSource):
skill_identifier = f"{repo}/{prefix}/{dir_name}" if prefix else f"{repo}/{dir_name}"
meta = self.inspect(skill_identifier)
if meta:
if groupings:
category = groupings.get(meta.name) or groupings.get(dir_name)
if category:
meta.extra["category"] = category
skills.append(meta)
# Cache the results
@@ -764,6 +781,61 @@ class GitHubSource(SkillSource):
logger.debug("GitHub contents API fetch failed: %s", e)
return None
def _get_skillsh_groupings(self, repo: str) -> Optional[Dict[str, str]]:
"""Fetch and parse the repo-root ``skills.sh.json`` grouping sidecar.
``skills.sh.json`` is a published cross-ecosystem standard
(``$schema: https://skills.sh/schemas/skills.sh.schema.json``) that
lets a tap declare human-readable category groupings for its skills:
{"groupings": [{"title": "Inference AI", "skills": ["dynamo-..."]}]}
We flatten it into ``{skill_name: grouping_title}`` so the Skills Hub
UI can show a real category pill instead of a tag-derived guess. Any
tap that ships this file gets categorization for free this is not
NVIDIA-specific.
Returns the map (possibly empty) on success, or ``None`` when the repo
has no sidecar / it couldn't be parsed. Cached per-repo on the instance.
"""
if repo in self._skillsh_groupings:
return self._skillsh_groupings[repo]
content = self._fetch_file_content(repo, "skills.sh.json")
groupings = self._parse_skillsh_groupings(content) if content else None
self._skillsh_groupings[repo] = groupings
return groupings
@staticmethod
def _parse_skillsh_groupings(content: str) -> Optional[Dict[str, str]]:
"""Flatten a ``skills.sh.json`` document into ``{skill_name: title}``.
Returns ``None`` when the content isn't a usable grouping document.
"""
try:
data = json.loads(content)
except (json.JSONDecodeError, TypeError):
return None
if not isinstance(data, dict):
return None
groupings = data.get("groupings")
if not isinstance(groupings, list):
return None
mapping: Dict[str, str] = {}
for group in groupings:
if not isinstance(group, dict):
continue
title = group.get("title")
members = group.get("skills")
if not isinstance(title, str) or not isinstance(members, list):
continue
for member in members:
if isinstance(member, str) and member:
# First grouping wins if a skill is listed twice.
mapping.setdefault(member, title)
return mapping
def _read_cache(self, key: str) -> Optional[list]:
"""Read cached index if not expired."""
cache_file = INDEX_CACHE_DIR / f"{key}.json"
@@ -797,6 +869,7 @@ class GitHubSource(SkillSource):
"repo": meta.repo,
"path": meta.path,
"tags": meta.tags,
"extra": meta.extra,
}
@staticmethod
@@ -1217,6 +1290,16 @@ class SkillsShSource(SkillSource):
BASE_URL = "https://skills.sh"
SEARCH_URL = f"{BASE_URL}/api/search"
# Sitemap index — the real catalog source. The homepage scrape only
# exposes a curated featured strip (~200 entries); the sitemap covers
# the full ~20k+ catalog. https://www.skills.sh/sitemap.xml points at
# sitemap-skills-1.xml + sitemap-skills-2.xml, each up to 10k URLs.
SITEMAP_INDEX_URL = "https://www.skills.sh/sitemap.xml"
_SITEMAP_LOC_RE = re.compile(r"<loc>([^<]+)</loc>", re.IGNORECASE)
_SITEMAP_SKILL_RE = re.compile(
r"^https?://(?:www\.)?skills\.sh/(?P<owner>[^/]+)/(?P<repo>[^/]+)/(?P<skill>[^/]+)/?$",
re.IGNORECASE,
)
_SKILL_LINK_RE = re.compile(r'href=["\']/(?P<id>(?!agents/|_next/|api/)[^"\'/]+/[^"\'/]+/[^"\'/]+)["\']')
_INSTALL_CMD_RE = re.compile(
r'npx\s+skills\s+add\s+(?P<repo>https?://github\.com/[^\s<]+|[^\s<]+)'
@@ -1246,7 +1329,10 @@ class SkillsShSource(SkillSource):
def search(self, query: str, limit: int = 10) -> List[SkillMeta]:
if not query.strip():
return self._featured_skills(limit)
# Empty query = bulk catalog dump (what build_skills_index.py
# calls with). The homepage scrape only sees ~200 featured
# entries; the sitemap walks the full ~20k+ catalog.
return self._sitemap_catalog(limit)
cache_key = f"skills_sh_search_{hashlib.md5(f'{query}|{limit}'.encode()).hexdigest()}"
cached = _read_index_cache(cache_key)
@@ -1307,6 +1393,97 @@ class SkillsShSource(SkillSource):
return self._finalize_inspect_meta(meta, canonical, detail)
return None
def _sitemap_catalog(self, limit: int) -> List[SkillMeta]:
"""Walk the skills.sh sitemap to enumerate the full catalog.
Cached for the standard index TTL so we don't refetch ~2 MB of
sitemap XML per build. Falls back to ``_featured_skills`` if the
sitemap is unreachable or empty (network failure, hostname
change, etc.).
"""
cache_key = "skills_sh_sitemap_v1"
cached = _read_index_cache(cache_key)
if cached is not None:
metas = [SkillMeta(**item) for item in cached]
return metas[:limit] if limit > 0 else metas
# skills.sh serves the per-skill sitemaps brotli-compressed, and
# httpx's optional brotlicffi backend has a streaming-decode bug
# that fails on these specific payloads. Excluding "br" from
# Accept-Encoding makes the server fall back to gzip (or
# identity), which works on every httpx install.
sitemap_headers = {"Accept-Encoding": "gzip"}
# Step 1: fetch the sitemap index → list of skill-sitemap URLs.
skill_sitemap_urls: List[str] = []
try:
resp = httpx.get(
self.SITEMAP_INDEX_URL,
timeout=20,
follow_redirects=True,
headers=sitemap_headers,
)
if resp.status_code != 200:
return self._featured_skills(limit)
for match in self._SITEMAP_LOC_RE.finditer(resp.text):
loc = match.group(1).strip()
# Sitemap index entries that point at the per-skill maps.
if "sitemap-skills" in loc:
skill_sitemap_urls.append(loc)
except httpx.HTTPError:
return self._featured_skills(limit)
if not skill_sitemap_urls:
return self._featured_skills(limit)
# Step 2: fetch each skill sitemap and collect canonical "owner/repo/skill" IDs.
seen: set[str] = set()
results: List[SkillMeta] = []
for sitemap_url in skill_sitemap_urls:
try:
resp = httpx.get(
sitemap_url,
timeout=30,
follow_redirects=True,
headers=sitemap_headers,
)
if resp.status_code != 200:
continue
except httpx.HTTPError:
continue
for loc_match in self._SITEMAP_LOC_RE.finditer(resp.text):
url = loc_match.group(1).strip()
m = self._SITEMAP_SKILL_RE.match(url)
if not m:
continue
owner = m.group("owner")
repo_name = m.group("repo")
skill_name = m.group("skill")
canonical = f"{owner}/{repo_name}/{skill_name}"
if canonical in seen:
continue
seen.add(canonical)
repo = f"{owner}/{repo_name}"
results.append(SkillMeta(
name=skill_name,
description=f"Indexed by skills.sh from {repo}",
source="skills.sh",
identifier=self._wrap_identifier(canonical),
trust_level=self.github.trust_level_for(canonical),
repo=repo,
path=skill_name,
extra={
"detail_url": f"{self.BASE_URL}/{canonical}",
"repo_url": f"https://github.com/{repo}",
},
))
if not results:
return self._featured_skills(limit)
_write_index_cache(cache_key, [_skill_meta_to_dict(item) for item in results])
return results[:limit] if limit > 0 else results
def _featured_skills(self, limit: int) -> List[SkillMeta]:
cache_key = "skills_sh_featured"
cached = _read_index_cache(cache_key)
@@ -1859,8 +2036,18 @@ class ClawHubSource(SkillSource):
results = self._search_catalog(query, limit=limit)
if results:
return results
else:
# Empty query: route through the paginating catalog walker so the
# full ClawHub catalog (20k+ skills) lands in the index. The
# single-request listing path below caps at one page (200 items)
# regardless of `limit`, which silently truncates the public
# skills index. The catalog walker follows `nextCursor`.
catalog = self._load_catalog_index()
if catalog:
return self._dedupe_results(catalog)[:limit] if limit > 0 else self._dedupe_results(catalog)
# Empty query or catalog fallback failure: use the lightweight listing API.
# Non-empty query catalog miss, or catalog walker failure: fall back to
# the lightweight listing API for a best-effort response.
cache_key = f"clawhub_search_listing_v1_{hashlib.md5(query.encode()).hexdigest()}_{limit}"
cached = _read_index_cache(cache_key)
if cached is not None:
@@ -1989,7 +2176,12 @@ class ClawHubSource(SkillSource):
cursor: Optional[str] = None
results: List[SkillMeta] = []
seen: set[str] = set()
max_pages = 50
# ClawHub has 50k+ skills as of May 2026 (live E2E walked 49,698 with
# an active cursor still pending); 750 pages * 200/page = 150k ceiling
# leaves room for catalog growth. Walk-to-exhaustion typically
# terminates well before this on `nextCursor` going None — the cap is
# a safety rail against an infinite-cursor loop.
max_pages = 750
for _ in range(max_pages):
params: Dict[str, Any] = {"limit": 200}
-43
View File
@@ -629,49 +629,6 @@ def _sort_skills(skills: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
return sorted(skills, key=lambda s: (s.get("category") or "", s["name"]))
def _load_category_description(category_dir: Path) -> Optional[str]:
"""
Load category description from DESCRIPTION.md if it exists.
Args:
category_dir: Path to the category directory
Returns:
Description string or None if not found
"""
desc_file = category_dir / "DESCRIPTION.md"
if not desc_file.exists():
return None
try:
content = desc_file.read_text(encoding="utf-8")
# Parse frontmatter if present
frontmatter, body = _parse_frontmatter(content)
# Prefer frontmatter description, fall back to first non-header line
description = frontmatter.get("description", "")
if not description:
for line in body.strip().split("\n"):
line = line.strip()
if line and not line.startswith("#"):
description = line
break
# Truncate to reasonable length
if len(description) > MAX_DESCRIPTION_LENGTH:
description = description[: MAX_DESCRIPTION_LENGTH - 3] + "..."
return description if description else None
except (UnicodeDecodeError, PermissionError) as e:
logger.debug("Failed to read category description %s: %s", desc_file, e)
return None
except Exception as e:
logger.warning(
"Error parsing category description %s: %s", desc_file, e, exc_info=True
)
return None
def skills_list(category: str = None, task_id: str = None) -> str:
"""
List all available skills (progressive disclosure tier 1 - minimal metadata).
+164 -12
View File
@@ -71,6 +71,7 @@ from tools.tool_backend_helpers import (
coerce_modal_mode,
has_direct_modal_credentials,
managed_nous_tools_enabled,
nous_tool_gateway_unavailable_message,
resolve_modal_backend_state,
)
@@ -860,6 +861,78 @@ _creation_locks_lock = threading.Lock() # Protects _creation_locks dict itself
_cleanup_thread = None
_cleanup_running = False
# Once-per-process guard for the docker orphan reaper (issue #20561).
# Set when _maybe_reap_docker_orphans first runs; concurrent _create_environment
# calls for parallel subagents won't re-trigger the sweep.
_docker_orphan_reaper_ran = False
_docker_orphan_reaper_lock = threading.Lock()
def _maybe_reap_docker_orphans(container_config: Dict[str, Any]) -> None:
"""Run the docker orphan reaper once per process, if enabled.
Sweeps long-Exited containers labeled ``hermes-agent=1`` for the current
profile that match the issue #20561 leak class — containers left behind
by Hermes processes that exited without firing ``atexit`` (SIGKILL,
OOM, terminal-window-close). The reaper is conservative by default:
only Exited containers older than ``2 × lifetime_seconds`` and scoped to
the current profile.
Gates:
* ``terminal.docker_orphan_reaper: false`` disables it entirely (the
operator opted out usually because they're running multiple
Hermes processes in the same profile and don't trust the
conservative defaults).
* ``_docker_orphan_reaper_ran`` flag sweep runs once per Python
interpreter, not on every subagent / RL-rollout / parallel
``terminal()`` call.
"""
global _docker_orphan_reaper_ran
if not container_config.get("docker_orphan_reaper", True):
return
# Cheap double-checked-locking: read without the lock, take the lock
# only on first run, recheck inside.
if _docker_orphan_reaper_ran:
return
with _docker_orphan_reaper_lock:
if _docker_orphan_reaper_ran:
return
_docker_orphan_reaper_ran = True
# 2 × lifetime_seconds gives sibling Hermes processes a generous grace
# window. Floor at 60s so an operator with TERMINAL_LIFETIME_SECONDS=0
# doesn't get an instant-reap that races their own setup.
# ``container_config`` only carries container_* keys, so read
# lifetime_seconds from the env var the rest of the module uses.
try:
lifetime = int(os.getenv("TERMINAL_LIFETIME_SECONDS", "300"))
except (TypeError, ValueError):
lifetime = 300
lifetime = max(60, lifetime)
max_age = lifetime * 2
try:
from tools.environments.docker import (
reap_orphan_containers, _get_active_profile_name,
)
except ImportError:
return
try:
profile = _get_active_profile_name()
removed = reap_orphan_containers(
max_age_seconds=max_age, profile_filter=profile,
)
if removed:
logger.info(
"Docker orphan reaper removed %d stale container(s) for profile %s",
removed, profile,
)
except Exception as e:
# Never fail the env-creation path because of a janitor problem.
logger.debug("Docker orphan reaper raised: %s", e)
# Per-task environment overrides registry.
# Allows environments (e.g., TerminalBench2Env) to specify a custom Docker/Modal
# image for a specific task_id BEFORE the agent loop starts. When the terminal or
@@ -1023,6 +1096,22 @@ def _get_env_config() -> Dict[str, Any]:
"docker_env": _parse_env_var("TERMINAL_DOCKER_ENV", "{}", json.loads, "valid JSON"),
"docker_run_as_host_user": os.getenv("TERMINAL_DOCKER_RUN_AS_HOST_USER", "false").lower() in {"true", "1", "yes"},
"docker_extra_args": _parse_env_var("TERMINAL_DOCKER_EXTRA_ARGS", "[]", json.loads, "valid JSON"),
# Cross-process container reuse (issue #20561). The docs claim
# "ONE long-lived container shared across sessions" — this toggle
# makes that real by probing for a labeled container at startup and
# attaching to it instead of always starting a fresh one. Set to
# ``false`` for hard per-process isolation (no reuse, container is
# removed on exit).
"docker_persist_across_processes": os.getenv(
"TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES", "true"
).lower() in {"true", "1", "yes"},
# Startup orphan reaper for hermes-tagged containers left behind by
# crashed / SIGKILL'd previous processes that bypassed atexit.
# Conservative: only sweeps Exited containers older than 2× the
# idle-reap window AND scoped to the current profile. Issue #20561.
"docker_orphan_reaper": os.getenv(
"TERMINAL_DOCKER_ORPHAN_REAPER", "true"
).lower() in {"true", "1", "yes"},
}
@@ -1071,6 +1160,13 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
return _LocalEnvironment(cwd=cwd, timeout=timeout)
elif env_type == "docker":
# One-shot orphan reaper: clean up labeled containers left behind by
# prior Hermes processes that hit SIGKILL / OOM / a closed terminal
# before the atexit cleanup hook could run. Gated to once per
# process so concurrent _create_environment calls (parallel
# subagents, RL benchmarks) don't run the reaper N times.
# Disable via ``terminal.docker_orphan_reaper: false`` (issue #20561).
_maybe_reap_docker_orphans(cc)
return _DockerEnvironment(
image=image, cwd=cwd, timeout=timeout,
cpu=cpu, memory=memory, disk=disk,
@@ -1082,6 +1178,7 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
env=docker_env,
run_as_host_user=cc.get("docker_run_as_host_user", False),
extra_args=docker_extra_args,
persist_across_processes=cc.get("docker_persist_across_processes", True),
)
elif env_type == "singularity":
@@ -1118,13 +1215,19 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
if modal_state["managed_mode_blocked"]:
raise ValueError(
"Modal backend is configured for managed mode, but "
"a paid Nous subscription is required for the Tool Gateway and no direct "
"Modal credentials/config were found. Log in with `hermes model` or "
"choose TERMINAL_MODAL_MODE=direct/auto."
"Nous Tool Gateway access is not currently available and no direct "
"Modal credentials/config were found. "
+ nous_tool_gateway_unavailable_message(
"managed Modal execution",
)
+ " Choose TERMINAL_MODAL_MODE=direct/auto to use direct Modal credentials."
)
if modal_state["mode"] == "managed":
raise ValueError(
"Modal backend is configured for managed mode, but the managed tool gateway is unavailable."
"Modal backend is configured for managed mode, but the managed tool gateway is unavailable. "
+ nous_tool_gateway_unavailable_message(
"managed Modal execution",
)
)
if modal_state["mode"] == "direct":
raise ValueError(
@@ -1323,8 +1426,27 @@ def cleanup_all_environments():
return cleaned
def cleanup_vm(task_id: str):
"""Manually clean up a specific environment by task_id."""
def cleanup_vm(task_id: str, *, force_remove: bool = False):
"""Manually clean up a specific environment by task_id.
*force_remove* (default False) is forwarded to backends that accept it
currently only ``DockerEnvironment``. The default of False matches
session-lifecycle semantics: this function is called from
``AIAgent.close()`` (TUI session close, gateway session teardown) and the
per-turn cleanup branch for non-persistent envs, both of which should
honor the user's persist-mode preference. Stopping the container here
would defeat the "ONE long-lived container shared across sessions"
contract exactly the bug Ben reported when the container was killed
on every TUI session close.
Pass ``force_remove=True`` for actual user-initiated teardown
(e.g. ``/reset``-style flows that haven't been wired yet, or future
"destroy my sandbox" commands).
The idle reaper passes the env through ``env.cleanup()`` directly (not
via this function), so persist-mode idle envs are similarly no-op'd —
only the orphan reaper at next startup reclaims them.
"""
# Remove from tracking dicts while holding the lock, but defer the
# actual (potentially slow) env.cleanup() call to outside the lock
# so other tool calls aren't blocked.
@@ -1349,7 +1471,14 @@ def cleanup_vm(task_id: str):
try:
if hasattr(env, 'cleanup'):
env.cleanup()
# Pass force_remove only if the env's cleanup() accepts it
# (DockerEnvironment after issue #20561; other backends don't).
import inspect
sig = inspect.signature(env.cleanup)
if "force_remove" in sig.parameters:
env.cleanup(force_remove=force_remove)
else:
env.cleanup()
elif hasattr(env, 'stop'):
env.stop()
elif hasattr(env, 'terminate'):
@@ -1371,7 +1500,23 @@ def _atexit_cleanup():
if _active_environments:
count = len(_active_environments)
logger.info("Shutting down %d remaining sandbox(es)...", count)
# Snapshot the env objects BEFORE cleanup_all_environments empties
# the dict; we need them to wait on docker cleanup threads after the
# registry has been cleared.
envs_to_wait = list(_active_environments.values())
cleanup_all_environments()
# Block briefly so docker stop/rm actually completes before the
# interpreter exits. Issue #20561 — without this join, the daemon
# cleanup threads were getting torn down mid-`docker stop`, leaving
# Exited containers piled up on the host.
for env in envs_to_wait:
wait_fn = getattr(env, "wait_for_cleanup", None)
if wait_fn is None:
continue
try:
wait_fn(timeout=15.0)
except Exception as e: # never block shutdown on a bad backend
logger.debug("wait_for_cleanup raised on exit: %s", e)
atexit.register(_atexit_cleanup)
@@ -1739,6 +1884,8 @@ def terminal_tool(
"docker_env": config.get("docker_env", {}),
"docker_run_as_host_user": config.get("docker_run_as_host_user", False),
"docker_extra_args": config.get("docker_extra_args", []),
"docker_persist_across_processes": config.get("docker_persist_across_processes", True),
"docker_orphan_reaper": config.get("docker_orphan_reaper", True),
}
local_config = None
@@ -2214,16 +2361,21 @@ def check_terminal_requirements() -> bool:
if modal_state["managed_mode_blocked"]:
logger.error(
"Modal backend selected with TERMINAL_MODAL_MODE=managed, but "
"a paid Nous subscription is required for the Tool Gateway and no direct "
"Modal credentials/config were found. Log in with `hermes model` "
"or choose TERMINAL_MODAL_MODE=direct/auto."
"Nous Tool Gateway access is not currently available and no direct "
"Modal credentials/config were found. %s Choose "
"TERMINAL_MODAL_MODE=direct/auto to use direct Modal credentials.",
nous_tool_gateway_unavailable_message(
"managed Modal execution",
),
)
return False
if modal_state["mode"] == "managed":
logger.error(
"Modal backend selected with TERMINAL_MODAL_MODE=managed, but the managed "
"tool gateway is unavailable. Configure the managed gateway or choose "
"TERMINAL_MODAL_MODE=direct/auto."
"tool gateway is unavailable. %s",
nous_tool_gateway_unavailable_message(
"managed Modal execution",
),
)
return False
elif modal_state["mode"] == "direct":
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""Propagate agent-turn context into worker threads that dispatch Hermes tools.
A bare ``threading.Thread`` / ``ThreadPoolExecutor`` worker starts with an
empty ``contextvars.Context`` and no thread-local approval/sudo callbacks.
Tool dispatch inside such a thread therefore silently loses:
* the approval *session/platform* ContextVars (``tools.approval`` /
``gateway.session_context``) so gateway sessions fall into
``check_dangerous_command``'s non-interactive auto-approve branch and
dangerous commands run without prompting (#33057, #30882);
* the thread-local CLI approval/sudo callbacks (``tools.terminal_tool``)
so ``prompt_dangerous_approval`` cannot reach the user
(GHSA-qg5c-hvr5-hjgr, #15216).
This helper factors out that capture/install/clear lifecycle so the several
places that fan tool dispatch onto worker threads (``agent.tool_executor`` and
the ``execute_code`` RPC threads) share one audited implementation instead of
divergent copies.
Usage call :func:`propagate_context_to_thread` **on the parent thread**
(it snapshots the parent's ContextVars and callbacks at call time) and use the
returned callable as the worker's target::
t = threading.Thread(target=propagate_context_to_thread(loop_fn), args=(...))
# or
executor.submit(propagate_context_to_thread(worker_fn), *args)
Approval/sudo callbacks are installed for the worker's lifetime and **always
cleared on exit**, so a recycled thread never holds a stale reference to a
disposed CLI instance.
"""
from __future__ import annotations
import contextvars
import logging
from typing import Callable
logger = logging.getLogger(__name__)
def _callback_api():
"""Resolve the terminal_tool callback getters/setters.
Imported lazily: ``tools.terminal_tool`` imports ``tools.approval`` at
module load, so a top-level import here would risk an import cycle for
callers that live in ``tools.approval``.
"""
from tools.terminal_tool import (
_get_approval_callback,
_get_sudo_password_callback,
set_approval_callback,
set_sudo_password_callback,
)
return (
_get_approval_callback,
_get_sudo_password_callback,
set_approval_callback,
set_sudo_password_callback,
)
def propagate_context_to_thread(target: Callable) -> Callable:
"""Wrap *target* for execution on a worker thread with the *current*
thread's ContextVars and approval/sudo callbacks propagated.
Call this on the parent thread; pass the returned callable as the
thread/executor target. The returned callable forwards its positional
and keyword arguments to *target* and returns its result.
Fail-closed: if callback installation raises, the callbacks are left
unset (``None``). That is the safe outcome ``prompt_dangerous_approval``
denies dangerous commands when no callback is registered in an interactive
context, and the gateway approval queue blocks when its notify callback is
absent.
"""
ctx = contextvars.copy_context()
parent_approval_cb = parent_sudo_cb = None
setters = None
try:
get_approval, get_sudo, set_approval, set_sudo = _callback_api()
parent_approval_cb = get_approval()
parent_sudo_cb = get_sudo()
setters = (set_approval, set_sudo)
except Exception:
logger.debug("Could not capture parent approval/sudo callbacks", exc_info=True)
def _runner(*args, **kwargs):
def _inner():
if setters is not None:
set_approval, set_sudo = setters
try:
if parent_approval_cb is not None:
set_approval(parent_approval_cb)
if parent_sudo_cb is not None:
set_sudo(parent_sudo_cb)
except Exception:
logger.debug(
"Failed to install propagated approval/sudo callbacks; "
"dangerous-command approval will fail closed",
exc_info=True,
)
try:
return target(*args, **kwargs)
finally:
if setters is not None:
set_approval, set_sudo = setters
try:
set_approval(None)
set_sudo(None)
except Exception:
logger.debug(
"Failed to clear propagated approval/sudo callbacks",
exc_info=True,
)
return ctx.run(_inner)
return _runner
+29 -12
View File
@@ -326,6 +326,32 @@ def _verify_checksum(archive_path: str, checksums_path: str, archive_name: str)
return True
def _extract_tirith_binary(tar: tarfile.TarFile, dest_dir: str, log) -> tuple[str | None, str]:
"""Extract the tirith binary from a release archive into dest_dir."""
for member in tar.getmembers():
if member.name == "tirith" or member.name.endswith("/tirith"):
if ".." in member.name:
continue
if not member.isfile():
log("tirith archive member is not a regular file: %s", member.name)
return None, "binary_not_regular_file"
src_file = tar.extractfile(member)
if src_file is None:
log("tirith binary could not be read from archive")
return None, "binary_extract_failed"
dest_path = os.path.join(dest_dir, "tirith")
try:
with open(dest_path, "wb") as out:
shutil.copyfileobj(src_file, out)
finally:
src_file.close()
return dest_path, ""
log("tirith binary not found in archive")
return None, "binary_not_in_archive"
def _install_tirith(*, log_failures: bool = True) -> tuple[str | None, str]:
"""Download and install tirith to $HERMES_HOME/bin/tirith.
@@ -394,19 +420,10 @@ def _install_tirith(*, log_failures: bool = True) -> tuple[str | None, str]:
return None, "checksum_failed"
with tarfile.open(archive_path, "r:gz") as tar:
# Extract only the tirith binary (safety: reject paths with ..)
for member in tar.getmembers():
if member.name == "tirith" or member.name.endswith("/tirith"):
if ".." in member.name:
continue
member.name = "tirith"
tar.extract(member, tmpdir)
break
else:
log("tirith binary not found in archive")
return None, "binary_not_in_archive"
src, reason = _extract_tirith_binary(tar, tmpdir, log)
if src is None:
return None, reason
src = os.path.join(tmpdir, "tirith")
dest = os.path.join(_hermes_bin_dir(), "tirith")
try:
shutil.move(src, dest)
+51 -18
View File
@@ -14,29 +14,55 @@ _DEFAULT_MODAL_MODE = "auto"
_VALID_MODAL_MODES = {"auto", "direct", "managed"}
def managed_nous_tools_enabled() -> bool:
"""Return True when the user has an active paid Nous subscription.
def managed_nous_tools_enabled(*, force_fresh: bool = False) -> bool:
"""Return True when the user has paid Nous Portal service access.
The Tool Gateway is available to any Nous subscriber who is NOT on
the free tier. We intentionally catch all exceptions and return
False never block the agent startup path.
Tool Gateway availability fails closed on unknown/error entitlement. We
intentionally catch all exceptions and return False never block startup.
``force_fresh=True`` is for interactive configuration flows that should
reflect a just-purchased subscription or credits immediately.
"""
try:
from hermes_cli.auth import get_nous_auth_status
from hermes_cli.nous_account import get_nous_portal_account_info
status = get_nous_auth_status()
if not status.get("logged_in"):
if force_fresh:
account_info = get_nous_portal_account_info(force_fresh=True)
else:
account_info = get_nous_portal_account_info()
if not account_info.logged_in:
return False
from hermes_cli.models import check_nous_free_tier
if check_nous_free_tier():
return False # free-tier users don't get gateway access
return True
return account_info.paid_service_access is True
except Exception:
return False
def nous_tool_gateway_unavailable_message(
capability: str = "the Nous Tool Gateway",
*,
force_fresh: bool = False,
) -> str:
"""Return account-aware guidance for an unavailable Nous Tool Gateway path."""
try:
from hermes_cli.nous_account import (
format_nous_portal_entitlement_message,
get_nous_portal_account_info,
)
account_info = get_nous_portal_account_info(force_fresh=force_fresh)
message = format_nous_portal_entitlement_message(
account_info,
capability=capability,
)
if message:
return message
except Exception:
pass
return (
f"{capability} is unavailable. Run `hermes model` to refresh your "
"Nous Portal login and billing status."
)
def normalize_browser_cloud_provider(value: object | None) -> str:
"""Return a normalized browser provider key."""
provider = str(value or _DEFAULT_BROWSER_PROVIDER).strip().lower()
@@ -58,9 +84,13 @@ def normalize_modal_mode(value: object | None) -> str:
def has_direct_modal_credentials() -> bool:
"""Return True when direct Modal credentials/config are available."""
try:
modal_file_exists = (Path.home() / ".modal.toml").exists()
except (PermissionError, OSError):
modal_file_exists = False
return bool(
(os.getenv("MODAL_TOKEN_ID") and os.getenv("MODAL_TOKEN_SECRET"))
or (Path.home() / ".modal.toml").exists()
or modal_file_exists
)
@@ -69,6 +99,7 @@ def resolve_modal_backend_state(
*,
has_direct: bool,
managed_ready: bool,
managed_enabled: bool | None = None,
) -> Dict[str, Any]:
"""Resolve direct vs managed Modal backend selection.
@@ -79,16 +110,18 @@ def resolve_modal_backend_state(
"""
requested_mode = coerce_modal_mode(modal_mode)
normalized_mode = normalize_modal_mode(modal_mode)
if managed_enabled is None:
managed_enabled = managed_nous_tools_enabled()
managed_mode_blocked = (
requested_mode == "managed" and not managed_nous_tools_enabled()
requested_mode == "managed" and not managed_enabled
)
if normalized_mode == "managed":
selected_backend = "managed" if managed_nous_tools_enabled() and managed_ready else None
selected_backend = "managed" if managed_enabled and managed_ready else None
elif normalized_mode == "direct":
selected_backend = "direct" if has_direct else None
else:
selected_backend = "managed" if managed_nous_tools_enabled() and managed_ready else "direct" if has_direct else None
selected_backend = "managed" if managed_enabled and managed_ready else "direct" if has_direct else None
return {
"requested_mode": requested_mode,
+735
View File
@@ -0,0 +1,735 @@
"""Progressive tool disclosure ("tool search") for Hermes Agent.
When enabled, MCP and non-core plugin tools are replaced in the model-visible
tools array by three bridge tools ``tool_search``, ``tool_describe``,
``tool_call`` and surfaced on demand. Core Hermes tools never defer.
Design constraints this module is built around (see ``openclaw-tool-search-report``
for the full rationale):
* Core tools defined in ``toolsets._HERMES_CORE_TOOLS`` are *never* deferred.
Always-load means always-load. No exceptions.
* The threshold gate runs every assembly: when deferrable tools would consume
less than ``threshold_pct`` of the model's context window (default 10%),
tool search is a no-op and the tools array passes through unchanged.
* The catalog is stateless across turns and tools-array assemblies. It is
rebuilt from the current tool-defs list every time. This is the lesson
from OpenClaw's cron regression (openclaw/openclaw#84141): a session-keyed
catalog that drifts out of sync with the live tool registry produces
silent tool dropouts.
* Bridge tools route through ``model_tools.handle_function_call`` exactly
like a direct call, so guardrails, plugin pre/post hooks, approval flows,
and tool-result truncation all fire identically.
* Display and trajectory unwrap is implemented here so the user (CLI activity
feed, gateway, saved trajectories) always sees the underlying tool, not
the bridge.
"""
from __future__ import annotations
import json
import logging
import math
import re
from dataclasses import dataclass, field
from typing import Any, Dict, Iterable, List, Optional, Tuple
logger = logging.getLogger("tools.tool_search")
# Bridge tool names. These names are reserved and may not collide with a
# user/plugin/MCP tool — registration of any tool with these names is
# rejected by the registry's existing override-protection logic.
TOOL_SEARCH_NAME = "tool_search"
TOOL_DESCRIBE_NAME = "tool_describe"
TOOL_CALL_NAME = "tool_call"
BRIDGE_TOOL_NAMES = frozenset({TOOL_SEARCH_NAME, TOOL_DESCRIBE_NAME, TOOL_CALL_NAME})
# When estimating tokens from char count without a real tokenizer, this is
# the cheap rule of thumb that's stable across providers. Roughly 4 chars
# per token for English+JSON. Underestimating leads to false negatives
# (tool search not activated when it should); overestimating leads to false
# positives (activated when not needed). 4.0 errs slightly toward
# underestimating, which is the safer default.
CHARS_PER_TOKEN = 4.0
# ---------------------------------------------------------------------------
# Configuration plumbing
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class ToolSearchConfig:
"""Resolved, validated tool-search configuration for a single assembly."""
enabled: str # "auto" | "on" | "off"
threshold_pct: float # 0..100 — only used when enabled == "auto"
search_default_limit: int
max_search_limit: int
@classmethod
def from_raw(cls, raw: Any) -> "ToolSearchConfig":
"""Build a config from a raw dict / bool / None.
Accepts the legacy bool shape (``tools.tool_search: true``) and the
dict shape (``tools.tool_search: {enabled: auto, ...}``). Validates
and clamps every numeric field; unknown values fall back to safe
defaults rather than raising, so a typo in user config does not
break the agent.
"""
if raw is True:
return cls(enabled="auto", threshold_pct=10.0,
search_default_limit=5, max_search_limit=20)
if raw is False:
return cls(enabled="off", threshold_pct=10.0,
search_default_limit=5, max_search_limit=20)
if not isinstance(raw, dict):
return cls(enabled="auto", threshold_pct=10.0,
search_default_limit=5, max_search_limit=20)
enabled_raw = str(raw.get("enabled", "auto")).strip().lower()
if enabled_raw in ("true", "1", "yes"):
enabled = "on"
elif enabled_raw in ("false", "0", "no"):
enabled = "off"
elif enabled_raw in ("auto", "on", "off"):
enabled = enabled_raw
else:
enabled = "auto"
threshold_pct = _safe_float(raw.get("threshold_pct"), 10.0)
threshold_pct = max(0.0, min(100.0, threshold_pct))
max_search_limit = max(1, min(50, _safe_int(raw.get("max_search_limit"), 20)))
search_default_limit = max(1, min(max_search_limit,
_safe_int(raw.get("search_default_limit"), 5)))
return cls(
enabled=enabled,
threshold_pct=threshold_pct,
search_default_limit=search_default_limit,
max_search_limit=max_search_limit,
)
def _safe_int(value: Any, fallback: int) -> int:
try:
return int(value)
except (TypeError, ValueError):
return fallback
def _safe_float(value: Any, fallback: float) -> float:
try:
return float(value)
except (TypeError, ValueError):
return fallback
def load_config() -> ToolSearchConfig:
"""Load tool-search config from the user config file."""
try:
from hermes_cli.config import load_config as _load
cfg = _load() or {}
tools_cfg = cfg.get("tools") if isinstance(cfg.get("tools"), dict) else {}
if not isinstance(tools_cfg, dict):
tools_cfg = {}
return ToolSearchConfig.from_raw(tools_cfg.get("tool_search"))
except Exception as e:
logger.debug("Failed to load tool-search config: %s", e)
return ToolSearchConfig.from_raw(None)
# ---------------------------------------------------------------------------
# Tool classification
# ---------------------------------------------------------------------------
def _core_tool_names() -> frozenset[str]:
"""Return the set of tool names that must NEVER be deferred.
Imported lazily because ``toolsets`` imports from ``tools.registry``
and we don't want a hard cycle.
"""
try:
from toolsets import _HERMES_CORE_TOOLS
return frozenset(_HERMES_CORE_TOOLS)
except Exception:
return frozenset()
def is_deferrable_tool_name(name: str) -> bool:
"""Return True if a tool with this name is *eligible* for deferral.
A tool is deferrable iff it is registered with an MCP toolset prefix
OR it is not in ``_HERMES_CORE_TOOLS``. Core tools are never deferred
even when their toolset is technically plugin-provided (this protects
against accidental shadowing).
"""
if name in BRIDGE_TOOL_NAMES:
return False
if name in _core_tool_names():
return False
# Check registry toolset for MCP prefix.
try:
from tools.registry import registry
entry = registry.get_entry(name)
if entry is None:
return False
if entry.toolset.startswith("mcp-"):
return True
# Non-MCP, non-core → plugin tool, eligible.
return True
except Exception:
return False
def classify_tools(tool_defs: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
"""Split a tool-defs list into (visible, deferrable).
``visible`` retains every tool that must stay in the model-facing array:
every core tool, plus any tool we can't classify. ``deferrable`` is the
candidate set for catalog entry.
"""
visible: List[Dict[str, Any]] = []
deferrable: List[Dict[str, Any]] = []
for td in tool_defs:
fn = td.get("function") or {}
name = fn.get("name", "")
if name in BRIDGE_TOOL_NAMES:
# Should never happen — bridge tools are added after classification —
# but be defensive.
continue
if is_deferrable_tool_name(name):
deferrable.append(td)
else:
visible.append(td)
return visible, deferrable
# ---------------------------------------------------------------------------
# Token estimation and threshold gate
# ---------------------------------------------------------------------------
def estimate_tokens_from_schemas(tool_defs: Iterable[Dict[str, Any]]) -> int:
"""Estimate the token cost of a tool-defs list via the chars/4 rule.
Cheap and stable across providers. The number doesn't need to be exact —
it gates the activate/skip decision, and a typical 200K context with a
10% threshold means the decision flips around 20K tokens of schema.
Order-of-magnitude precision is fine.
"""
total_chars = 0
for td in tool_defs:
try:
total_chars += len(json.dumps(td, ensure_ascii=False, separators=(",", ":")))
except (TypeError, ValueError):
total_chars += len(str(td))
return int(math.ceil(total_chars / CHARS_PER_TOKEN))
def should_activate(
config: ToolSearchConfig,
deferrable_tokens: int,
context_length: Optional[int],
) -> bool:
"""Decide whether tool search should activate for the current assembly.
``"off"`` skips unconditionally. ``"on"`` activates unconditionally
(as long as there is at least one deferrable tool there's no point
swapping a no-op). ``"auto"`` activates when the deferrable schemas
would consume ``threshold_pct`` of context or more.
"""
if config.enabled == "off":
return False
if deferrable_tokens <= 0:
return False
if config.enabled == "on":
return True
# auto
if not context_length or context_length <= 0:
# Without a known context size, fall back to a fixed 20K-token cutoff
# — the cliff above which Anthropic and OpenAI both saw quality drops.
return deferrable_tokens >= 20_000
threshold_tokens = int(context_length * (config.threshold_pct / 100.0))
return deferrable_tokens >= threshold_tokens
# ---------------------------------------------------------------------------
# Catalog + BM25 retrieval
# ---------------------------------------------------------------------------
@dataclass
class CatalogEntry:
"""One deferrable tool, in a form the bridge tools can search and serve."""
name: str
description: str
schema: Dict[str, Any] # The full {"type":"function", "function": {...}} entry.
source: str # "mcp" | "plugin" | "other"
source_name: str # Toolset name, e.g. "mcp-github" or "kanban"
# Pre-tokenized fields for BM25.
_tokens: List[str] = field(default_factory=list)
_TOKEN_RE = re.compile(r"[A-Za-z0-9]+")
def _tokenize(text: str) -> List[str]:
if not text:
return []
return [t.lower() for t in _TOKEN_RE.findall(text)]
def _entry_search_text(td: Dict[str, Any]) -> str:
"""Build the search-text blob for a deferrable tool.
Includes the tool name (with underscores broken into words so BM25 can
match against query terms), the description, and the names of the
top-level parameters. Schema bodies are deliberately excluded
indexing them adds noise without improving recall in our measurement.
"""
fn = td.get("function") or {}
name = fn.get("name", "")
desc = fn.get("description", "") or ""
params = ((fn.get("parameters") or {}).get("properties") or {})
param_names = " ".join(params.keys())
# Break snake_case and dotted names into words for BM25.
name_words = name.replace("_", " ").replace(".", " ").replace("-", " ").replace(":", " ")
return f"{name_words} {desc} {param_names}"
def _classify_source(name: str) -> Tuple[str, str]:
"""Return (source_kind, source_name) for a registered tool name."""
try:
from tools.registry import registry
entry = registry.get_entry(name)
if entry is None:
return ("other", "")
if entry.toolset.startswith("mcp-"):
return ("mcp", entry.toolset)
return ("plugin", entry.toolset)
except Exception:
return ("other", "")
def build_catalog(tool_defs: List[Dict[str, Any]]) -> List[CatalogEntry]:
"""Build the deferred-tool catalog from a tool-defs list.
Caller is expected to pass only the deferrable subset (``classify_tools``
returns it as the second element).
"""
catalog: List[CatalogEntry] = []
for td in tool_defs:
fn = td.get("function") or {}
name = fn.get("name", "")
if not name:
continue
desc = fn.get("description", "") or ""
source, source_name = _classify_source(name)
entry = CatalogEntry(
name=name,
description=desc,
schema=td,
source=source,
source_name=source_name,
_tokens=_tokenize(_entry_search_text(td)),
)
catalog.append(entry)
return catalog
def _bm25_score(query_tokens: List[str], doc_tokens: List[str],
doc_lengths: List[int], avg_dl: float,
doc_freq: Dict[str, int], n_docs: int,
k1: float = 1.5, b: float = 0.75) -> float:
"""Standard BM25 score for one query against one document.
Inlined small implementation rather than adding a dependency. Performance
is fine the catalog is bounded by N (tools) typically < 500, and we
score against the in-memory tokens list.
"""
if not doc_tokens:
return 0.0
score = 0.0
dl = len(doc_tokens)
# Pre-count tokens in the doc.
doc_tf: Dict[str, int] = {}
for t in doc_tokens:
doc_tf[t] = doc_tf.get(t, 0) + 1
for q in query_tokens:
df = doc_freq.get(q, 0)
if df == 0:
continue
idf = math.log(1 + (n_docs - df + 0.5) / (df + 0.5))
tf = doc_tf.get(q, 0)
if tf == 0:
continue
norm = tf * (k1 + 1) / (tf + k1 * (1 - b + b * dl / max(avg_dl, 1.0)))
score += idf * norm
return score
def search_catalog(catalog: List[CatalogEntry], query: str, limit: int = 5) -> List[CatalogEntry]:
"""Return the top-``limit`` catalog entries for ``query`` by BM25.
Falls back to a stable name-substring match when BM25 yields no hits
above zero. That ensures a query like ``"github"`` against a catalog
where every tool is named ``github_*`` still returns results BM25
can underperform when query and document share only one token that
appears in every document (zero IDF).
"""
if not catalog or limit <= 0:
return []
query_tokens = _tokenize(query)
if not query_tokens:
return []
# Precompute doc statistics.
doc_lengths = [len(e._tokens) for e in catalog]
avg_dl = sum(doc_lengths) / max(len(doc_lengths), 1)
doc_freq: Dict[str, int] = {}
for e in catalog:
seen = set(e._tokens)
for t in seen:
doc_freq[t] = doc_freq.get(t, 0) + 1
n_docs = len(catalog)
scored: List[Tuple[float, CatalogEntry]] = []
for entry in catalog:
s = _bm25_score(query_tokens, entry._tokens, doc_lengths, avg_dl,
doc_freq, n_docs)
if s > 0:
scored.append((s, entry))
if not scored:
# Substring fallback against the original tool name.
ql = query.lower()
for entry in catalog:
if ql in entry.name.lower():
scored.append((0.1, entry))
scored.sort(key=lambda x: x[0], reverse=True)
return [e for _, e in scored[:limit]]
# ---------------------------------------------------------------------------
# Bridge tool schemas
# ---------------------------------------------------------------------------
def bridge_tool_schemas(deferred_count: int) -> List[Dict[str, Any]]:
"""Build the bridge tool schemas to inject in place of deferred tools.
The schemas are intentionally short every byte added here is a byte
the user pays on every turn. Descriptions are tuned to be unambiguous
about the call sequence the model should follow.
"""
desc_search = (
f"Search {deferred_count} additional tools that are loaded on demand. "
"Returns up to ``limit`` matches with name and description. Follow "
f"with `{TOOL_DESCRIBE_NAME}` to load a tool's full parameter schema, "
f"then `{TOOL_CALL_NAME}` to invoke it. Tools listed at the top of this "
"system prompt are already available and do not need to be searched."
)
desc_describe = (
f"Load the full JSON schema for one tool returned by `{TOOL_SEARCH_NAME}`. "
f"Required before `{TOOL_CALL_NAME}` if the tool's parameters are unknown."
)
desc_call = (
"Invoke a deferred tool by name with the given arguments. Argument shape "
f"matches the tool's schema (see `{TOOL_DESCRIBE_NAME}`). Policy, hooks, "
"and approvals run exactly as for any directly-listed tool."
)
return [
{
"type": "function",
"function": {
"name": TOOL_SEARCH_NAME,
"description": desc_search,
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Keywords describing the capability you need (e.g. 'create github issue').",
},
"limit": {
"type": "integer",
"description": "Maximum number of results to return. Default 5.",
},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": TOOL_DESCRIBE_NAME,
"description": desc_describe,
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Exact tool name (as returned by tool_search).",
},
},
"required": ["name"],
},
},
},
{
"type": "function",
"function": {
"name": TOOL_CALL_NAME,
"description": desc_call,
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Exact tool name to invoke.",
},
"arguments": {
"type": "object",
"description": "Arguments for the tool, matching its schema.",
},
},
"required": ["name", "arguments"],
},
},
},
]
# ---------------------------------------------------------------------------
# Public entry point: assemble tool-defs with optional tool search
# ---------------------------------------------------------------------------
@dataclass
class AssemblyResult:
"""Outcome of one assembly. Useful for tests and observability."""
tool_defs: List[Dict[str, Any]]
activated: bool
deferred_count: int = 0
deferred_tokens: int = 0
threshold_tokens: int = 0
def assemble_tool_defs(
tool_defs: List[Dict[str, Any]],
*,
context_length: Optional[int] = None,
config: Optional[ToolSearchConfig] = None,
) -> AssemblyResult:
"""Return the tool-defs list the model should actually see.
When tool search is inactive (off, no deferrable tools, or below
threshold), this is a passthrough. When active, MCP and plugin tools
are stripped from the visible list and replaced with the three bridge
tools. Core tools are *never* deferred regardless of config.
Idempotent: calling with bridge tools already in the input is a no-op
(they classify as non-core/non-deferrable but their names are reserved,
so they are filtered out of the deferrable set).
"""
if config is None:
config = load_config()
# Defensive: strip any bridge tools that may already be in the list
# (e.g. someone called assemble twice).
incoming = [td for td in tool_defs
if (td.get("function") or {}).get("name") not in BRIDGE_TOOL_NAMES]
visible, deferrable = classify_tools(incoming)
if not deferrable:
return AssemblyResult(tool_defs=incoming, activated=False)
deferrable_tokens = estimate_tokens_from_schemas(deferrable)
if not should_activate(config, deferrable_tokens, context_length):
return AssemblyResult(
tool_defs=incoming,
activated=False,
deferred_count=len(deferrable),
deferred_tokens=deferrable_tokens,
threshold_tokens=int((context_length or 0) * (config.threshold_pct / 100.0)),
)
bridge = bridge_tool_schemas(len(deferrable))
result = visible + bridge
threshold_tokens = int((context_length or 0) * (config.threshold_pct / 100.0))
logger.info(
"tool_search activated: %d core/visible tools kept, %d deferred (~%d tokens, threshold ~%d)",
len(visible), len(deferrable), deferrable_tokens, threshold_tokens,
)
return AssemblyResult(
tool_defs=result,
activated=True,
deferred_count=len(deferrable),
deferred_tokens=deferrable_tokens,
threshold_tokens=threshold_tokens,
)
# ---------------------------------------------------------------------------
# Bridge tool dispatch
# ---------------------------------------------------------------------------
def is_bridge_tool(name: str) -> bool:
return name in BRIDGE_TOOL_NAMES
def _format_search_hit(entry: CatalogEntry) -> Dict[str, Any]:
return {
"name": entry.name,
"source": entry.source,
"source_name": entry.source_name,
# Cap description so a chatty MCP server doesn't blow up the result.
"description": (entry.description or "")[:400],
}
def dispatch_tool_search(args: Dict[str, Any],
*,
current_tool_defs: List[Dict[str, Any]],
config: Optional[ToolSearchConfig] = None) -> str:
"""Execute the ``tool_search`` bridge tool. Returns a JSON string."""
if config is None:
config = load_config()
query = str(args.get("query") or "").strip()
if not query:
return json.dumps({"error": "query is required"}, ensure_ascii=False)
raw_limit = args.get("limit")
if raw_limit is None:
limit = config.search_default_limit
else:
limit = max(1, min(config.max_search_limit, _safe_int(raw_limit, config.search_default_limit)))
_, deferrable = classify_tools(current_tool_defs)
catalog = build_catalog(deferrable)
hits = search_catalog(catalog, query, limit=limit)
return json.dumps({
"query": query,
"total_available": len(catalog),
"matches": [_format_search_hit(h) for h in hits],
}, ensure_ascii=False)
def dispatch_tool_describe(args: Dict[str, Any],
*,
current_tool_defs: List[Dict[str, Any]]) -> str:
"""Execute the ``tool_describe`` bridge tool. Returns a JSON string."""
name = str(args.get("name") or "").strip()
if not name:
return json.dumps({"error": "name is required"}, ensure_ascii=False)
if not is_deferrable_tool_name(name):
return json.dumps({
"error": (
f"'{name}' is not a deferrable tool. If you see it in the tools list "
"already, call it directly; otherwise check the spelling against tool_search."
),
}, ensure_ascii=False)
_, deferrable = classify_tools(current_tool_defs)
for td in deferrable:
fn = td.get("function") or {}
if fn.get("name") == name:
return json.dumps({
"name": name,
"description": fn.get("description", ""),
"parameters": fn.get("parameters", {}),
}, ensure_ascii=False)
return json.dumps({
"error": f"'{name}' is not currently available. Re-run tool_search to refresh.",
}, ensure_ascii=False)
def scoped_deferrable_names(tool_defs: List[Dict[str, Any]]) -> frozenset[str]:
"""Return the set of deferrable tool names present in ``tool_defs``.
``tool_defs`` is expected to be the *pre-assembly* tool list for the
current session's toolset scope (i.e. what
``get_tool_definitions(skip_tool_search_assembly=True)`` returns for the
session's enabled/disabled toolsets). The resulting set is the universe of
tools the session may legitimately reach through ``tool_call``. Used as a
scoping gate by both the ``model_tools`` bridge dispatch and the
``tool_executor`` unwrap so a restricted-toolset session can never invoke
an out-of-scope tool via the bridge.
"""
names: set[str] = set()
for td in tool_defs:
name = (td.get("function") or {}).get("name", "")
if name and is_deferrable_tool_name(name):
names.add(name)
return frozenset(names)
def resolve_underlying_call(args: Dict[str, Any]) -> Tuple[Optional[str], Dict[str, Any], Optional[str]]:
"""Parse a ``tool_call`` invocation into (underlying_name, args, error_msg).
Used by:
* the dispatcher in ``model_tools.handle_function_call``,
* the display layer (so the activity feed shows the underlying tool),
* the trajectory recorder.
On parse error, returns ``(None, {}, error_message)``.
"""
name = str(args.get("name") or "").strip()
if not name:
return None, {}, "tool_call requires a 'name' argument"
if name in BRIDGE_TOOL_NAMES:
return None, {}, f"tool_call cannot invoke '{name}' (it is itself a bridge tool)"
raw_args = args.get("arguments")
if raw_args is None:
raw_args = {}
if isinstance(raw_args, str):
try:
raw_args = json.loads(raw_args)
except json.JSONDecodeError as e:
return None, {}, f"tool_call 'arguments' is not valid JSON: {e}"
if not isinstance(raw_args, dict):
return None, {}, "tool_call 'arguments' must be an object"
if not is_deferrable_tool_name(name):
return None, {}, (
f"'{name}' is not a deferrable tool. If it appears in the model-facing tools "
"list already, call it directly instead of via tool_call."
)
return name, raw_args, None
__all__ = [
"TOOL_SEARCH_NAME",
"TOOL_DESCRIBE_NAME",
"TOOL_CALL_NAME",
"BRIDGE_TOOL_NAMES",
"ToolSearchConfig",
"CatalogEntry",
"AssemblyResult",
"load_config",
"is_deferrable_tool_name",
"classify_tools",
"estimate_tokens_from_schemas",
"should_activate",
"build_catalog",
"search_catalog",
"bridge_tool_schemas",
"assemble_tool_defs",
"is_bridge_tool",
"dispatch_tool_search",
"dispatch_tool_describe",
"resolve_underlying_call",
"scoped_deferrable_names",
]
+26 -11
View File
@@ -39,7 +39,11 @@ from urllib.parse import urljoin
from utils import is_truthy_value
from tools.managed_tool_gateway import resolve_managed_tool_gateway
from tools.tool_backend_helpers import managed_nous_tools_enabled, resolve_openai_audio_api_key
from tools.tool_backend_helpers import (
managed_nous_tools_enabled,
nous_tool_gateway_unavailable_message,
resolve_openai_audio_api_key,
)
logger = logging.getLogger(__name__)
@@ -791,16 +795,11 @@ def _get_provider(stt_config: dict) -> str:
return "none"
if provider == "mistral":
# `mistralai` PyPI package was quarantined on 2026-05-12 after a
# malicious 2.4.6 release. Refuse to use this provider until it's
# available again so we surface a clear message instead of an
# opaque ImportError mid-call.
if _HAS_MISTRAL and get_env_value("MISTRAL_API_KEY"):
return "mistral"
logger.warning(
"STT provider 'mistral' (Voxtral Transcribe) is temporarily "
"disabled — `mistralai` PyPI package is quarantined "
"(malicious 2.4.6 release on 2026-05-12). Falling back to "
"another provider. Set stt.provider in config.yaml to 'local' "
"or 'openai' to silence this warning."
"STT provider 'mistral' configured but mistralai package "
"not installed or MISTRAL_API_KEY not set"
)
return "none"
@@ -841,6 +840,12 @@ def _get_provider(stt_config: dict) -> str:
if _HAS_OPENAI and _has_openai_audio_backend():
logger.info("No local STT available, using OpenAI Whisper API")
return "openai"
# Only auto-select Mistral if the SDK is already present — don't trigger a
# lazy-install during passive auto-detection. Explicit `provider: mistral`
# (above) does lazy-install on first transcription call.
if _HAS_MISTRAL and get_env_value("MISTRAL_API_KEY"):
logger.info("No local STT available, using Mistral Voxtral Transcribe API")
return "mistral"
try:
from tools.xai_http import resolve_xai_http_credentials
@@ -1381,6 +1386,11 @@ def _transcribe_mistral(file_path: str, model_name: str) -> Dict[str, Any]:
return {"success": False, "transcript": "", "error": "MISTRAL_API_KEY not set"}
try:
try:
from tools.lazy_deps import ensure as _lazy_ensure
_lazy_ensure("stt.mistral", prompt=False)
except ImportError:
pass
from mistralai.client import Mistral
with Mistral(api_key=api_key) as client:
@@ -1749,7 +1759,12 @@ def _resolve_openai_audio_client_config() -> tuple[str, str]:
if managed_gateway is None:
message = "Neither stt.openai.api_key in config nor VOICE_TOOLS_OPENAI_KEY/OPENAI_API_KEY is set"
if managed_nous_tools_enabled():
message += ", and the managed OpenAI audio gateway is unavailable"
message += (
". "
+ nous_tool_gateway_unavailable_message(
"managed OpenAI audio for transcription",
)
)
raise ValueError(message)
return managed_gateway.nous_user_token, urljoin(
+37 -19
View File
@@ -69,7 +69,12 @@ def get_env_value(name, default=None):
value = _get_env_value(name)
return default if value is None else value
from tools.managed_tool_gateway import resolve_managed_tool_gateway
from tools.tool_backend_helpers import managed_nous_tools_enabled, prefers_gateway, resolve_openai_audio_api_key
from tools.tool_backend_helpers import (
managed_nous_tools_enabled,
nous_tool_gateway_unavailable_message,
prefers_gateway,
resolve_openai_audio_api_key,
)
from tools.xai_http import hermes_xai_user_agent
# ---------------------------------------------------------------------------
@@ -116,7 +121,20 @@ def _import_openai_client():
return OpenAIClient
def _import_mistral_client():
"""Lazy import Mistral client. Returns the class or raises ImportError."""
"""Lazy import Mistral client. Returns the class or raises ImportError.
Calls :func:`tools.lazy_deps.ensure` first so the ``mistralai`` SDK gets
installed on demand if the user picked Mistral as their STT/TTS provider
but never ran the post-setup hook (e.g. enabled it by editing config.yaml
directly). Mirrors the ElevenLabs lazy-import path.
"""
try:
from tools.lazy_deps import ensure
ensure("tts.mistral", prompt=False)
except ImportError:
pass
except Exception as e: # FeatureUnavailable or any unexpected error
raise ImportError(str(e))
from mistralai.client import Mistral
return Mistral
@@ -1969,21 +1987,16 @@ def text_to_speech_tool(
_generate_xai_tts(text, file_str, tts_config)
elif provider == "mistral":
# `mistralai` PyPI package was quarantined on 2026-05-12 after a
# malicious 2.4.6 release. Surface a clear status message instead
# of attempting an import that would either fail or pull a stale
# cached package.
return json.dumps({
"success": False,
"error": (
"Mistral Voxtral TTS is temporarily disabled. The "
"`mistralai` PyPI package was quarantined on 2026-05-12 "
"after a malicious 2.4.6 release. Switch tts.provider in "
"config.yaml to 'edge', 'elevenlabs', 'openai', 'minimax', "
"'gemini', 'xai', 'neutts', or 'kittentts'. Mistral "
"support will return once PyPI un-quarantines the package."
),
}, ensure_ascii=False)
try:
_import_mistral_client()
except ImportError:
return json.dumps({
"success": False,
"error": "Mistral provider selected but 'mistralai' package not installed. "
"Run: pip install 'hermes-agent[mistral]'"
}, ensure_ascii=False)
logger.info("Generating speech with Mistral Voxtral TTS...")
_generate_mistral_tts(text, file_str, tts_config)
elif provider == "gemini":
logger.info("Generating speech with Google Gemini TTS...")
@@ -2206,8 +2219,13 @@ def _resolve_openai_audio_client_config() -> tuple[str, str]:
managed_gateway = resolve_managed_tool_gateway("openai-audio")
if managed_gateway is None:
message = "Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set"
if managed_nous_tools_enabled():
message += ", and the managed OpenAI audio gateway is unavailable"
if managed_nous_tools_enabled() or prefers_gateway("tts"):
message += (
". "
+ nous_tool_gateway_unavailable_message(
"managed OpenAI audio for TTS",
)
)
raise ValueError(message)
return managed_gateway.nous_user_token, urljoin(
+39 -22
View File
@@ -476,6 +476,36 @@ def _supports_media_in_tool_results(provider: str, model: str) -> bool:
return False
def _should_use_native_vision_fast_path() -> bool:
"""Whether vision tools should attach the image to the main model directly
instead of routing through the auxiliary vision LLM.
True when image routing resolves to ``native`` AND either the provider is
known to accept images inside tool results, or the user explicitly declared
the model vision-capable via the ``model.supports_vision`` config override.
The override is the escape hatch for custom/local providers that aren't in
the static allowlist. Best-effort: any resolution failure returns False so
the caller falls back to the legacy aux-LLM path.
"""
try:
from agent.auxiliary_client import _read_main_provider, _read_main_model
from agent.image_routing import decide_image_input_mode, _lookup_supports_vision
from hermes_cli.config import load_config
provider = _read_main_provider()
model = _read_main_model()
cfg = load_config()
if decide_image_input_mode(provider, model, cfg) != "native":
return False
return (
_supports_media_in_tool_results(provider, model)
or _lookup_supports_vision(provider, model, cfg) is True
)
except Exception as exc:
logger.debug("Native vision fast-path check failed: %s", exc)
return False
def _build_native_vision_tool_result(
image_url: str,
question: str,
@@ -1030,28 +1060,15 @@ def _handle_vision_analyze(args: Dict[str, Any], **kw: Any) -> Awaitable[str]:
image_url = args.get("image_url", "")
question = args.get("question", "")
# Fast path: when the active main model supports native vision AND the
# provider supports image content inside tool results, short-circuit
# the auxiliary LLM and return the image bytes as a multimodal
# tool-result envelope. The main model sees the pixels directly on its
# next turn — no aux call, no information loss, no extra latency.
try:
from agent.auxiliary_client import _read_main_provider, _read_main_model
from agent.image_routing import decide_image_input_mode
from hermes_cli.config import load_config
_provider = _read_main_provider()
_model = _read_main_model()
_cfg = load_config()
_mode = decide_image_input_mode(_provider, _model, _cfg)
if _mode == "native" and _supports_media_in_tool_results(_provider, _model):
logger.info(
"vision_analyze: native fast path (provider=%s, model=%s)",
_provider, _model,
)
return _vision_analyze_native(image_url, question)
except Exception as exc:
logger.debug("Native vision fast-path check failed; using aux LLM: %s", exc)
# Fast path: when native image routing is in effect for the active main
# model (provider accepts images in tool results, or the user set the
# model.supports_vision override), short-circuit the auxiliary LLM and
# return the image bytes as a multimodal tool-result envelope. The main
# model sees the pixels directly on its next turn — no aux call, no
# information loss, no extra latency.
if _should_use_native_vision_fast_path():
logger.info("vision_analyze: native fast path")
return _vision_analyze_native(image_url, question)
# Legacy path: aux LLM describes the image and we return its text.
full_prompt = (
+16 -7
View File
@@ -97,6 +97,9 @@ def detect_audio_environment() -> dict:
termux_mic_cmd = _termux_microphone_command()
termux_app_installed = _termux_api_app_installed()
termux_capture = bool(termux_mic_cmd and termux_app_installed)
has_forwarded_audio = bool(
os.environ.get('PULSE_SERVER') or os.environ.get('PIPEWIRE_REMOTE')
)
# SSH detection
if any(os.environ.get(v) for v in ('SSH_CLIENT', 'SSH_TTY', 'SSH_CONNECTION')):
@@ -108,7 +111,7 @@ def detect_audio_environment() -> dict:
# (issue #21203). Only block when no forwarding is configured.
from hermes_constants import is_container
if is_container():
if os.environ.get('PULSE_SERVER') or os.environ.get('PIPEWIRE_REMOTE'):
if has_forwarded_audio:
notices.append("Running inside container (Docker/Podman/LXC) with host audio forwarding")
else:
warnings.append(
@@ -143,17 +146,22 @@ def detect_audio_environment() -> dict:
try:
devices = sd.query_devices()
if not devices:
if os.environ.get('PULSE_SERVER'):
notices.append("No PortAudio devices detected but PULSE_SERVER is set -- continuing")
if has_forwarded_audio:
notices.append(
"No PortAudio devices detected but host audio forwarding is configured -- continuing"
)
elif termux_capture:
notices.append("No PortAudio devices detected, but Termux:API microphone capture is available")
else:
warnings.append("No audio input/output devices detected")
except Exception:
# In WSL with PulseAudio, device queries can fail even though
# recording/playback works fine. Don't block if PULSE_SERVER is set.
if os.environ.get('PULSE_SERVER'):
notices.append("Audio device query failed but PULSE_SERVER is set -- continuing")
# recording/playback works fine. Don't block if host audio
# forwarding is configured.
if has_forwarded_audio:
notices.append(
"Audio device query failed but host audio forwarding is configured -- continuing"
)
elif termux_capture:
notices.append("PortAudio device query failed, but Termux:API microphone capture is available")
else:
@@ -1090,7 +1098,8 @@ def check_voice_requirements() -> Dict[str, Any]:
details_parts.append("STT provider: OK (OpenAI)")
else:
details_parts.append(
"STT provider: MISSING (pip install faster-whisper, "
"STT provider: MISSING (uv pip install faster-whisper "
"`pip install faster-whisper` also works if pip is on PATH, "
"or set GROQ_API_KEY / VOICE_TOOLS_OPENAI_KEY)"
)
+46 -261
View File
@@ -10,13 +10,12 @@ for Nous Subscribers only.
Available tools:
- web_search_tool: Search the web for information
- web_extract_tool: Extract content from specific web pages
- web_crawl_tool: Crawl websites with specific instructions
Backend compatibility:
- Exa: https://exa.ai (search, extract)
- Firecrawl: https://docs.firecrawl.dev/introduction (search, extract, crawl; direct or derived firecrawl-gateway.<domain> for Nous Subscribers)
- Firecrawl: https://docs.firecrawl.dev/introduction (search, extract; direct or derived firecrawl-gateway.<domain> for Nous Subscribers)
- Parallel: https://docs.parallel.ai (search, extract)
- Tavily: https://tavily.com (search, extract, crawl)
- Tavily: https://tavily.com (search, extract)
LLM Processing:
- Uses OpenRouter API with Gemini 3 Flash Preview for intelligent content extraction
@@ -28,16 +27,13 @@ Debug Mode:
- Captures all tool calls, results, and compression metrics
Usage:
from web_tools import web_search_tool, web_extract_tool, web_crawl_tool
from web_tools import web_search_tool, web_extract_tool
# Search the web
results = web_search_tool("Python machine learning libraries", limit=3)
# Extract content from URLs
content = web_extract_tool(["https://example.com"], format="markdown")
# Crawl a website
crawl_data = web_crawl_tool("example.com", "Find contact information")
"""
import json
@@ -55,21 +51,11 @@ import httpx # noqa: F401 — kept at module top so tests can patch tools.web_t
if TYPE_CHECKING:
from firecrawl import Firecrawl # noqa: F401 — type hints only
from plugins.web.firecrawl.provider import (
Firecrawl,
_FirecrawlProxy,
_FIRECRAWL_CLS_CACHE,
_extract_scrape_payload,
_extract_web_search_results,
Firecrawl, # noqa: F401 # re-exported for tests that mock.patch("tools.web_tools.Firecrawl")
_firecrawl_backend_help_suffix,
_get_direct_firecrawl_config,
_get_firecrawl_client,
_get_firecrawl_client, # noqa: F401 # re-exported for tests that `from tools.web_tools import _get_firecrawl_client`
_get_firecrawl_gateway_url,
_has_direct_firecrawl_config,
_is_tool_gateway_ready,
_load_firecrawl_cls,
_normalize_result_list,
_raise_web_backend_configuration_error,
_to_plain_object,
check_firecrawl_api_key,
)
# Tavily helpers re-exported for backward-compat with existing unit tests
@@ -110,9 +96,12 @@ from tools.managed_tool_gateway import ( # noqa: F401 — backward-compat names
read_nous_access_token as _read_nous_access_token,
resolve_managed_tool_gateway,
)
from tools.tool_backend_helpers import managed_nous_tools_enabled, prefers_gateway # noqa: F401
from tools.tool_backend_helpers import ( # noqa: F401
managed_nous_tools_enabled,
nous_tool_gateway_unavailable_message,
prefers_gateway,
)
from tools.url_safety import is_safe_url
from tools.website_policy import check_website_access
import sys
logger = logging.getLogger(__name__)
@@ -367,7 +356,7 @@ async def process_content_with_llm(
if content_len > MAX_CONTENT_SIZE:
size_mb = content_len / 1_000_000
logger.warning("Content too large (%.1fMB > 2MB limit). Refusing to process.", size_mb)
return f"[Content too large to process: {size_mb:.1f}MB. Try using web_crawl with specific extraction instructions, or search for a more focused source.]"
return f"[Content too large to process: {size_mb:.1f}MB. Try a more focused source URL.]"
# Skip processing if content is too short
if content_len < min_length:
@@ -743,6 +732,35 @@ def clean_base64_images(text: str) -> str:
# dispatchers in this file resolve them via get_active_*_provider().
def _ensure_web_plugins_loaded() -> None:
"""Idempotently trigger plugin discovery so the web registry is populated.
Every bundled web provider (brave-free, ddgs, searxng, exa, parallel,
tavily, firecrawl) registers itself via ``plugins/web/<vendor>/__init__.py``
during plugin discovery. Tool dispatch can be reached from contexts that
haven't already triggered discovery — subprocess agent runs, delegate
children, standalone scripts, certain test paths and without it the
registry is empty and ``get_provider('firecrawl')`` returns ``None`` even
when the user has ``web.extract_backend: firecrawl`` configured and
``FIRECRAWL_API_KEY`` set. The symptom is a misleading "No web extract
provider configured" error (issue #27580).
Mirrors :func:`tools.browser_tool._ensure_browser_plugins_loaded` exactly:
the underlying discovery call is idempotent and cheap on subsequent
invocations.
"""
try:
from hermes_cli.plugins import _ensure_plugins_discovered
_ensure_plugins_discovered()
except Exception as exc: # noqa: BLE001
# Warning, not debug: if a plugin import is genuinely broken the
# user otherwise hits the misleading "No web extract provider
# configured" error this helper is meant to eliminate, with no
# clue in normal logs about the real cause.
logger.warning("Web plugin discovery failed (non-fatal): %s", exc)
def web_search_tool(query: str, limit: int = 5) -> str:
"""
Search the web for information using available search API backend.
@@ -803,6 +821,7 @@ def web_search_tool(query: str, limit: int = 5) -> str:
# (brave-free, ddgs, searxng, exa, parallel, tavily, firecrawl)
# now live as plugins; the dispatcher is just a registry lookup +
# delegation. Sync only — every provider's search() is sync.
_ensure_web_plugins_loaded()
from agent.web_search_registry import (
get_active_search_provider,
get_provider as _wsp_get_provider,
@@ -935,6 +954,7 @@ async def web_extract_tool(
# detect coroutine functions and await; sync functions run
# inline (the policy gate, SSRF re-check, etc. live inside the
# provider itself for the firecrawl per-URL loop).
_ensure_web_plugins_loaded()
from agent.web_search_registry import (
get_active_extract_provider,
get_provider as _wsp_get_provider,
@@ -1130,239 +1150,6 @@ async def web_extract_tool(
return tool_error(error_msg)
async def web_crawl_tool(
url: str,
instructions: str = None,
depth: str = "basic",
use_llm_processing: bool = True,
model: Optional[str] = None,
min_length: int = DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION
) -> str:
"""
Crawl a website with specific instructions using available crawling API backend.
This function provides a generic interface for web crawling that can work
with multiple backends. Currently uses Firecrawl.
Args:
url (str): The base URL to crawl (can include or exclude https://)
instructions (str): Instructions for what to crawl/extract using LLM intelligence (optional)
depth (str): Depth of extraction ("basic" or "advanced", default: "basic")
use_llm_processing (bool): Whether to process content with LLM for summarization (default: True)
model (Optional[str]): The model to use for LLM processing (defaults to current auxiliary backend model)
min_length (int): Minimum content length to trigger LLM processing (default: 5000)
Returns:
str: JSON string containing crawled content. If LLM processing is enabled and successful,
the 'content' field will contain the processed markdown summary instead of raw content.
Each page is processed individually.
Raises:
Exception: If crawling fails or API key is not set
"""
debug_call_data = {
"parameters": {
"url": url,
"instructions": instructions,
"depth": depth,
"use_llm_processing": use_llm_processing,
"model": model,
"min_length": min_length
},
"error": None,
"pages_crawled": 0,
"pages_processed_with_llm": 0,
"original_response_size": 0,
"final_response_size": 0,
"compression_metrics": [],
"processing_applied": []
}
try:
effective_model = model or _get_default_summarizer_model()
auxiliary_available = check_auxiliary_model()
backend = _get_backend()
# Tavily (and any future plugin advertising supports_crawl=True)
# dispatches through agent.web_search_registry. The crawl response
# shape — {"results": [{"url", "title", "content", ...}]} — is then
# post-processed by the shared LLM-summarization path below.
from agent.web_search_registry import (
get_active_crawl_provider,
get_provider as _wsp_get_provider,
)
crawl_provider = _wsp_get_provider(backend) if backend else None
if crawl_provider is not None and not crawl_provider.supports_crawl():
# When the configured provider is search-only AND cannot
# extract URLs either (brave-free / ddgs / searxng), surface a
# typed "search-only" error rather than silently switching to
# a different crawl backend. When the provider supports extract
# but not crawl (e.g. firecrawl), fall through to the legacy
# firecrawl-via-extract path below.
if not crawl_provider.supports_extract():
return json.dumps(
{
"success": False,
"error": (
f"{crawl_provider.display_name} is a search-only "
"backend and cannot crawl URLs. "
"Set FIRECRAWL_API_KEY for crawling, or use "
"web_search instead."
),
},
ensure_ascii=False,
)
crawl_provider = None # let legacy firecrawl path handle it
if crawl_provider is None:
crawl_provider = get_active_crawl_provider()
# Mirror main's upstream availability gate: when the resolved
# provider is configured-but-unavailable (e.g. firecrawl without
# FIRECRAWL_API_KEY), short-circuit BEFORE we dispatch so the
# error envelope matches the legacy top-level shape
# ``{"success": False, "error": "..."}`` rather than burying the
# configuration message inside a per-page ``results[]`` entry.
if crawl_provider is not None and not crawl_provider.is_available():
return json.dumps(
{
"success": False,
"error": (
"web_crawl requires Firecrawl. Set FIRECRAWL_API_KEY, "
f"FIRECRAWL_API_URL{_firecrawl_backend_help_suffix()}, "
"or use web_search + web_extract instead."
),
},
ensure_ascii=False,
)
if crawl_provider is not None:
# Ensure URL has protocol
if not url.startswith(('http://', 'https://')):
url = f'https://{url}'
# SSRF protection — block private/internal addresses
if not is_safe_url(url):
return json.dumps({"results": [{"url": url, "title": "", "content": "",
"error": "Blocked: URL targets a private or internal network address"}]}, ensure_ascii=False)
# Website policy check
blocked = check_website_access(url)
if blocked:
logger.info("Blocked web_crawl for %s by rule %s", blocked["host"], blocked["rule"])
return json.dumps({"results": [{"url": url, "title": "", "content": "", "error": blocked["message"],
"blocked_by_policy": {"host": blocked["host"], "rule": blocked["rule"], "source": blocked["source"]}}]}, ensure_ascii=False)
from tools.interrupt import is_interrupted as _is_int
if _is_int():
return tool_error("Interrupted", success=False)
logger.info("Web crawl via %s: %s", crawl_provider.name, url)
# Async-or-sync dispatch — Tavily's crawl is sync, but a future
# async-crawl provider works transparently.
import inspect
crawl_kwargs = {"depth": depth, "limit": 20}
if instructions:
crawl_kwargs["instructions"] = instructions
if inspect.iscoroutinefunction(crawl_provider.crawl):
response = await crawl_provider.crawl(url, **crawl_kwargs)
else:
response = await asyncio.to_thread(
crawl_provider.crawl, url, **crawl_kwargs
)
# Provider returns {"results": [...]} matching what the shared
# LLM post-processing below expects.
if not isinstance(response, dict):
response = {"results": []}
response.setdefault("results", [])
# Fall through to the shared LLM processing and trimming below
# (skip the Firecrawl-specific crawl logic)
pages_crawled = len(response.get('results', []))
logger.info("Crawled %d pages", pages_crawled)
debug_call_data["pages_crawled"] = pages_crawled
debug_call_data["original_response_size"] = len(json.dumps(response))
# Process each result with LLM if enabled
if use_llm_processing and auxiliary_available:
logger.info("Processing crawled content with LLM (parallel)...")
debug_call_data["processing_applied"].append("llm_processing")
async def _process_tavily_crawl(result):
page_url = result.get('url', 'Unknown URL')
title = result.get('title', '')
content = result.get('content', '')
if not content:
return result, None, "no_content"
original_size = len(content)
processed = await process_content_with_llm(content, page_url, title, effective_model, min_length)
if processed:
result['raw_content'] = content
result['content'] = processed
metrics = {"url": page_url, "original_size": original_size, "processed_size": len(processed),
"compression_ratio": len(processed) / original_size if original_size else 1.0, "model_used": effective_model}
return result, metrics, "processed"
metrics = {"url": page_url, "original_size": original_size, "processed_size": original_size,
"compression_ratio": 1.0, "model_used": None, "reason": "content_too_short"}
return result, metrics, "too_short"
tasks = [_process_tavily_crawl(r) for r in response.get('results', [])]
# Use return_exceptions=True so a single task failure does not
# discard all other successfully processed crawl results.
processed_results = await asyncio.gather(*tasks, return_exceptions=True)
for result_item in processed_results:
if isinstance(result_item, BaseException):
logger.warning("Tavily crawl processing task failed: %s", result_item)
continue
result, metrics, status = result_item
if status == "processed":
debug_call_data["compression_metrics"].append(metrics)
debug_call_data["pages_processed_with_llm"] += 1
if use_llm_processing and not auxiliary_available:
logger.warning("LLM processing requested but no auxiliary model available, returning raw content")
debug_call_data["processing_applied"].append("llm_processing_unavailable")
trimmed_results = [{"url": r.get("url", ""), "title": r.get("title", ""), "content": r.get("content", ""), "error": r.get("error"),
**({ "blocked_by_policy": r["blocked_by_policy"]} if "blocked_by_policy" in r else {})} for r in response.get("results", [])]
result_json = json.dumps({"results": trimmed_results}, indent=2, ensure_ascii=False)
cleaned_result = clean_base64_images(result_json)
debug_call_data["final_response_size"] = len(cleaned_result)
_debug.log_call("web_crawl_tool", debug_call_data)
_debug.save()
return cleaned_result
# No registered provider supports crawl AND no crawl-capable plugin
# is available. Surface a typed error pointing the user at the two
# crawl-capable providers (Firecrawl + Tavily).
return json.dumps(
{
"success": False,
"error": (
"web_crawl has no available backend. "
"Set FIRECRAWL_API_KEY (or FIRECRAWL_API_URL for "
f"self-hosted){_firecrawl_backend_help_suffix()}, "
"or set TAVILY_API_KEY for Tavily. "
"Alternatively use web_search + web_extract instead."
),
},
ensure_ascii=False,
)
except Exception as e:
error_msg = f"Error crawling website: {str(e)}"
logger.debug("%s", error_msg)
debug_call_data["error"] = error_msg
_debug.log_call("web_crawl_tool", debug_call_data)
_debug.save()
return tool_error(error_msg)
# Convenience function to check Firecrawl credentials
def check_web_api_key() -> bool:
"""Check whether the configured web backend is available."""
@@ -1452,16 +1239,15 @@ if __name__ == "__main__":
print("🐛 Debug mode disabled (set WEB_TOOLS_DEBUG=true to enable)")
print("\nBasic usage:")
print(" from web_tools import web_search_tool, web_extract_tool, web_crawl_tool")
print(" from web_tools import web_search_tool, web_extract_tool")
print(" import asyncio")
print("")
print(" # Search (synchronous)")
print(" results = web_search_tool('Python tutorials')")
print("")
print(" # Extract and crawl (asynchronous)")
print(" # Extract (asynchronous)")
print(" async def main():")
print(" content = await web_extract_tool(['https://example.com'])")
print(" crawl_data = await web_crawl_tool('example.com', 'Find docs')")
print(" asyncio.run(main())")
if nous_available:
@@ -1470,9 +1256,8 @@ if __name__ == "__main__":
print(" content = await web_extract_tool(['https://python.org/about/'])")
print("")
print(" # Customize processing parameters")
print(" crawl_data = await web_crawl_tool(")
print(" 'docs.python.org',")
print(" 'Find key concepts',")
print(" content = await web_extract_tool(")
print(" ['https://docs.python.org'],")
print(" model='google/gemini-3-flash-preview',")
print(" min_length=3000")
print(" )")
+1 -1
View File
@@ -29,7 +29,7 @@ _DEFAULT_WEBSITE_BLOCKLIST = {
}
# Cache: parsed policy + timestamp. Avoids re-reading config.yaml on every
# URL check (a web_crawl with 50 pages would otherwise mean 51 YAML parses).
# URL check (a multi-URL extract with 50 pages would otherwise mean 51 YAML parses).
_CACHE_TTL_SECONDS = 30.0
_cache_lock = threading.Lock()
_cached_policy: Optional[Dict[str, Any]] = None
-1
View File
@@ -44,7 +44,6 @@ from __future__ import annotations
import json
import logging
import os
import time
from datetime import date, datetime, timezone
from typing import Any, Dict, List, Optional, Tuple