Merge branch 'main' into bb/gui
This commit is contained in:
+105
-9
@@ -98,6 +98,16 @@ def get_vnc_url() -> Optional[str]:
|
||||
return _vnc_url
|
||||
|
||||
|
||||
def _get_camofox_config() -> Dict[str, Any]:
|
||||
"""Return the ``browser.camofox`` config block, or an empty dict."""
|
||||
try:
|
||||
camofox_cfg = load_config().get("browser", {}).get("camofox", {})
|
||||
except Exception as exc:
|
||||
logger.warning("camofox config check failed, defaulting to disabled: %s", exc)
|
||||
return {}
|
||||
return camofox_cfg if isinstance(camofox_cfg, dict) else {}
|
||||
|
||||
|
||||
def _managed_persistence_enabled() -> bool:
|
||||
"""Return whether Hermes-managed persistence is enabled for Camofox.
|
||||
|
||||
@@ -107,12 +117,46 @@ def _managed_persistence_enabled() -> bool:
|
||||
|
||||
Controlled by ``browser.camofox.managed_persistence`` in config.yaml.
|
||||
"""
|
||||
try:
|
||||
camofox_cfg = load_config().get("browser", {}).get("camofox", {})
|
||||
except Exception as exc:
|
||||
logger.warning("managed_persistence check failed, defaulting to disabled: %s", exc)
|
||||
return bool(_get_camofox_config().get("managed_persistence"))
|
||||
|
||||
|
||||
def _camofox_identity_override(task_id: Optional[str], camofox_cfg: Dict[str, Any]) -> Optional[Dict[str, str]]:
|
||||
"""Return an externally configured Camofox identity, if one is set.
|
||||
|
||||
Integrations that own the visible Camofox browser can set a shared user ID
|
||||
so Hermes operates in the same browser profile instead of creating a
|
||||
separate private session.
|
||||
"""
|
||||
user_id = os.getenv("CAMOFOX_USER_ID", "").strip() or str(camofox_cfg.get("user_id") or "").strip()
|
||||
if not user_id:
|
||||
return None
|
||||
|
||||
session_key = (
|
||||
os.getenv("CAMOFOX_SESSION_KEY", "").strip()
|
||||
or str(camofox_cfg.get("session_key") or "").strip()
|
||||
or f"task_{(task_id or 'default')[:16]}"
|
||||
)
|
||||
return {"user_id": user_id, "session_key": session_key}
|
||||
|
||||
|
||||
def _env_flag(name: str) -> Optional[bool]:
|
||||
raw = os.getenv(name, "").strip().lower()
|
||||
if not raw:
|
||||
return None
|
||||
if raw in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if raw in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
return bool(camofox_cfg.get("managed_persistence"))
|
||||
logger.debug("Ignoring invalid boolean env %s=%r", name, raw)
|
||||
return None
|
||||
|
||||
|
||||
def _adopt_existing_tab_enabled(camofox_cfg: Dict[str, Any]) -> bool:
|
||||
"""Return whether Hermes should recover an existing Camofox tab ID."""
|
||||
env_value = _env_flag("CAMOFOX_ADOPT_EXISTING_TAB")
|
||||
if env_value is not None:
|
||||
return env_value
|
||||
return bool(camofox_cfg.get("adopt_existing_tab"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -123,6 +167,44 @@ _sessions: Dict[str, Dict[str, Any]] = {}
|
||||
_sessions_lock = threading.Lock()
|
||||
|
||||
|
||||
def _adopt_existing_tab(session: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Attach process-local state to an already-open managed Camofox tab.
|
||||
|
||||
Some integrations own the visible Camofox tab outside Hermes. Gateway
|
||||
restarts can leave this module's in-memory session cache empty even though
|
||||
Camofox still has that tab, so rehydrate tab_id before creating a new tab.
|
||||
"""
|
||||
if session.get("tab_id") or not session.get("adopt_existing_tab"):
|
||||
return session
|
||||
|
||||
if not get_camofox_url():
|
||||
return session
|
||||
|
||||
try:
|
||||
tabs = _get("/tabs", params={"userId": session["user_id"]}, timeout=5).get("tabs", [])
|
||||
except Exception as exc:
|
||||
logger.debug("Camofox tab adoption failed for %s: %s", session.get("user_id"), exc)
|
||||
return session
|
||||
|
||||
if not isinstance(tabs, list) or not tabs:
|
||||
return session
|
||||
|
||||
session_key = session.get("session_key")
|
||||
matching_tabs = [
|
||||
tab
|
||||
for tab in tabs
|
||||
if isinstance(tab, dict) and tab.get("listItemId") == session_key
|
||||
]
|
||||
candidates = matching_tabs or [tab for tab in tabs if isinstance(tab, dict)]
|
||||
latest = candidates[-1] if candidates else None
|
||||
tab_id = latest.get("tabId") if isinstance(latest, dict) else None
|
||||
if isinstance(tab_id, str) and tab_id:
|
||||
session["tab_id"] = tab_id
|
||||
logger.debug("Adopted existing Camofox tab %s for %s", tab_id, session.get("user_id"))
|
||||
|
||||
return session
|
||||
|
||||
|
||||
def _get_session(task_id: Optional[str]) -> Dict[str, Any]:
|
||||
"""Get or create a camofox session for the given task.
|
||||
|
||||
@@ -133,14 +215,26 @@ def _get_session(task_id: Optional[str]) -> Dict[str, Any]:
|
||||
task_id = task_id or "default"
|
||||
with _sessions_lock:
|
||||
if task_id in _sessions:
|
||||
return _sessions[task_id]
|
||||
if _managed_persistence_enabled():
|
||||
return _adopt_existing_tab(_sessions[task_id])
|
||||
|
||||
camofox_cfg = _get_camofox_config()
|
||||
identity_override = _camofox_identity_override(task_id, camofox_cfg)
|
||||
if identity_override:
|
||||
session = {
|
||||
"user_id": identity_override["user_id"],
|
||||
"tab_id": None,
|
||||
"session_key": identity_override["session_key"],
|
||||
"managed": True,
|
||||
"adopt_existing_tab": _adopt_existing_tab_enabled(camofox_cfg),
|
||||
}
|
||||
elif bool(camofox_cfg.get("managed_persistence")):
|
||||
identity = get_camofox_identity(task_id)
|
||||
session = {
|
||||
"user_id": identity["user_id"],
|
||||
"tab_id": None,
|
||||
"session_key": identity["session_key"],
|
||||
"managed": True,
|
||||
"adopt_existing_tab": _adopt_existing_tab_enabled(camofox_cfg),
|
||||
}
|
||||
else:
|
||||
session = {
|
||||
@@ -148,9 +242,10 @@ def _get_session(task_id: Optional[str]) -> Dict[str, Any]:
|
||||
"tab_id": None,
|
||||
"session_key": f"task_{task_id[:16]}",
|
||||
"managed": False,
|
||||
"adopt_existing_tab": False,
|
||||
}
|
||||
_sessions[task_id] = session
|
||||
return session
|
||||
return _adopt_existing_tab(session)
|
||||
|
||||
|
||||
def _ensure_tab(task_id: Optional[str], url: str = "about:blank") -> Dict[str, Any]:
|
||||
@@ -190,7 +285,8 @@ def camofox_soft_cleanup(task_id: Optional[str] = None) -> bool:
|
||||
does nothing and returns ``False`` so the caller can fall back to
|
||||
:func:`camofox_close`.
|
||||
"""
|
||||
if _managed_persistence_enabled():
|
||||
camofox_cfg = _get_camofox_config()
|
||||
if bool(camofox_cfg.get("managed_persistence")) or _camofox_identity_override(task_id, camofox_cfg):
|
||||
_drop_session(task_id)
|
||||
logger.debug("Camofox soft cleanup for task %s (managed persistence)", task_id)
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
"""Gateway-side clarify primitive (blocking event-based queue).
|
||||
|
||||
The ``clarify`` tool needs to ask the user a question and block the agent
|
||||
thread until they respond. In CLI mode this is trivial — ``input()`` is
|
||||
synchronous. In gateway mode the agent runs on a worker thread while the
|
||||
event loop handles the user's reply, so we need a thread-safe primitive
|
||||
that:
|
||||
|
||||
* stores a pending clarify request (with a generated ``clarify_id``),
|
||||
* blocks the agent thread on an ``Event``,
|
||||
* resolves the wait when the gateway's button-callback or text-intercept
|
||||
fires ``resolve_gateway_clarify(clarify_id, response)``,
|
||||
* supports timeouts so a user who never responds does NOT hang the agent
|
||||
thread forever (which would also pin the gateway's running-agent guard).
|
||||
|
||||
State is module-level (same shape as ``tools.approval``) so platform
|
||||
adapters can call ``resolve_gateway_clarify`` without holding a back-
|
||||
reference to the ``GatewayRunner`` instance.
|
||||
|
||||
Two delivery paths from the adapter:
|
||||
|
||||
1. **Button UI** — adapters override ``send_clarify`` to render inline
|
||||
buttons (e.g. Telegram ``InlineKeyboardMarkup``). The button
|
||||
callback resolves with the chosen string. A final "Other (type
|
||||
answer)" button enters text-capture mode for free-form responses.
|
||||
|
||||
2. **Text fallback** — adapters without rich UI render a numbered list.
|
||||
The user replies with a number ("2") or with free text; the gateway's
|
||||
``_handle_message`` intercepts the reply and resolves directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Module-level state
|
||||
# =========================================================================
|
||||
|
||||
@dataclass
|
||||
class _ClarifyEntry:
|
||||
"""One pending clarify request inside a gateway session."""
|
||||
clarify_id: str
|
||||
session_key: str
|
||||
question: str
|
||||
choices: Optional[List[str]]
|
||||
event: threading.Event = field(default_factory=threading.Event)
|
||||
response: Optional[str] = None
|
||||
awaiting_text: bool = False # set when user picked "Other" or clarify is open-ended
|
||||
|
||||
def signature(self) -> Dict[str, object]:
|
||||
return {
|
||||
"clarify_id": self.clarify_id,
|
||||
"session_key": self.session_key,
|
||||
"question": self.question,
|
||||
"choices": list(self.choices) if self.choices else None,
|
||||
}
|
||||
|
||||
|
||||
_lock = threading.RLock()
|
||||
# clarify_id → _ClarifyEntry (primary lookup for button callbacks)
|
||||
_entries: Dict[str, _ClarifyEntry] = {}
|
||||
# session_key → list[clarify_id] (FIFO; for text-fallback intercept and session cleanup)
|
||||
_session_index: Dict[str, List[str]] = {}
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Public API — agent-thread side
|
||||
# =========================================================================
|
||||
|
||||
def register(
|
||||
clarify_id: str,
|
||||
session_key: str,
|
||||
question: str,
|
||||
choices: Optional[List[str]],
|
||||
) -> _ClarifyEntry:
|
||||
"""Register a pending clarify request and return the entry.
|
||||
|
||||
The caller (gateway clarify_callback) will then send the prompt to the
|
||||
user and block on ``wait_for_response(clarify_id, timeout)``.
|
||||
"""
|
||||
entry = _ClarifyEntry(
|
||||
clarify_id=clarify_id,
|
||||
session_key=session_key,
|
||||
question=question,
|
||||
choices=list(choices) if choices else None,
|
||||
# Open-ended (no choices) → next message IS the response, no buttons needed.
|
||||
awaiting_text=not bool(choices),
|
||||
)
|
||||
with _lock:
|
||||
_entries[clarify_id] = entry
|
||||
_session_index.setdefault(session_key, []).append(clarify_id)
|
||||
return entry
|
||||
|
||||
|
||||
def wait_for_response(clarify_id: str, timeout: float) -> Optional[str]:
|
||||
"""Block on the entry's event until resolved or timeout fires.
|
||||
|
||||
Polls in 1-second slices so the agent's inactivity heartbeat keeps
|
||||
firing — without this, ``Event.wait(timeout=600)`` blocks the thread
|
||||
for 10 minutes with zero activity touches and the gateway's inactivity
|
||||
watchdog kills the agent while the user is still typing.
|
||||
|
||||
Returns the resolved response string, or ``None`` on timeout.
|
||||
"""
|
||||
with _lock:
|
||||
entry = _entries.get(clarify_id)
|
||||
if entry is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
from tools.environments.base import touch_activity_if_due
|
||||
except Exception: # pragma: no cover - optional
|
||||
touch_activity_if_due = None
|
||||
|
||||
deadline = time.monotonic() + max(timeout, 0.0)
|
||||
activity_state = {"last_touch": time.monotonic(), "start": time.monotonic()}
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
if entry.event.wait(timeout=min(1.0, remaining)):
|
||||
break
|
||||
if touch_activity_if_due is not None:
|
||||
touch_activity_if_due(activity_state, "waiting for user clarify response")
|
||||
|
||||
with _lock:
|
||||
# Remove from indices regardless of resolution outcome.
|
||||
_entries.pop(clarify_id, None)
|
||||
ids = _session_index.get(entry.session_key)
|
||||
if ids and clarify_id in ids:
|
||||
ids.remove(clarify_id)
|
||||
if not ids:
|
||||
_session_index.pop(entry.session_key, None)
|
||||
|
||||
return entry.response
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Public API — gateway / adapter side
|
||||
# =========================================================================
|
||||
|
||||
def resolve_gateway_clarify(clarify_id: str, response: str) -> bool:
|
||||
"""Unblock the agent thread waiting on ``clarify_id``.
|
||||
|
||||
Returns True if an entry was found and resolved, False otherwise
|
||||
(already resolved, expired, or never existed).
|
||||
"""
|
||||
with _lock:
|
||||
entry = _entries.get(clarify_id)
|
||||
if entry is None:
|
||||
return False
|
||||
entry.response = str(response) if response is not None else ""
|
||||
entry.event.set()
|
||||
return True
|
||||
|
||||
|
||||
def get_pending_for_session(session_key: str) -> Optional[_ClarifyEntry]:
|
||||
"""Return the OLDEST pending clarify entry for a session, or None.
|
||||
|
||||
Used by the text-fallback intercept in ``_handle_message`` — when a
|
||||
clarify is awaiting a free-form text response, the next user message
|
||||
in that session is captured as the answer.
|
||||
"""
|
||||
with _lock:
|
||||
ids = _session_index.get(session_key) or []
|
||||
for cid in ids:
|
||||
entry = _entries.get(cid)
|
||||
if entry is None:
|
||||
continue
|
||||
if entry.awaiting_text:
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
def mark_awaiting_text(clarify_id: str) -> bool:
|
||||
"""Flip an entry into text-capture mode (user picked the 'Other' button).
|
||||
|
||||
Returns True if the entry exists and was flipped, False otherwise.
|
||||
"""
|
||||
with _lock:
|
||||
entry = _entries.get(clarify_id)
|
||||
if entry is None:
|
||||
return False
|
||||
entry.awaiting_text = True
|
||||
return True
|
||||
|
||||
|
||||
def has_pending(session_key: str) -> bool:
|
||||
"""Return True when this session has at least one pending clarify entry."""
|
||||
with _lock:
|
||||
ids = _session_index.get(session_key) or []
|
||||
return any(_entries.get(cid) is not None for cid in ids)
|
||||
|
||||
|
||||
def clear_session(session_key: str) -> int:
|
||||
"""Resolve and drop every pending clarify for a session.
|
||||
|
||||
Used by session-boundary cleanup (e.g. ``/new``, gateway shutdown,
|
||||
cached-agent eviction) so blocked agent threads don't hang past the
|
||||
end of their session. Returns the number of entries cancelled.
|
||||
"""
|
||||
with _lock:
|
||||
ids = list(_session_index.pop(session_key, []) or [])
|
||||
entries = [_entries.pop(cid, None) for cid in ids]
|
||||
cancelled = 0
|
||||
for entry in entries:
|
||||
if entry is None:
|
||||
continue
|
||||
# Empty string sentinel — agent code can distinguish from a real
|
||||
# response by inspecting the wait_for_response return value
|
||||
# alongside its own timeout deadline. Most callers just treat any
|
||||
# falsy result as "user did not respond".
|
||||
entry.response = ""
|
||||
entry.event.set()
|
||||
cancelled += 1
|
||||
return cancelled
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Config
|
||||
# =========================================================================
|
||||
|
||||
def get_clarify_timeout() -> int:
|
||||
"""Read the clarify response timeout (seconds) from config.
|
||||
|
||||
Defaults to 600 (10 minutes) — long enough for the user to type a
|
||||
thoughtful response, short enough that an abandoned prompt eventually
|
||||
unblocks the agent thread instead of pinning the running-agent guard
|
||||
forever.
|
||||
|
||||
Reads ``agent.clarify_timeout`` from config.yaml.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config() or {}
|
||||
agent_cfg = cfg.get("agent", {}) or {}
|
||||
return int(agent_cfg.get("clarify_timeout", 600))
|
||||
except Exception:
|
||||
return 600
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Per-session notify hook (gateway → adapter bridge)
|
||||
# =========================================================================
|
||||
# Mirrors tools.approval's _gateway_notify_cbs: the gateway registers a
|
||||
# per-session callback that sends the clarify prompt to the user. The
|
||||
# callback bridges sync→async (runs on the agent thread; schedules the
|
||||
# adapter ``send_clarify`` call on the event loop).
|
||||
|
||||
_notify_cbs: Dict[str, Callable[[_ClarifyEntry], None]] = {}
|
||||
|
||||
|
||||
def register_notify(session_key: str, cb: Callable[[_ClarifyEntry], None]) -> None:
|
||||
"""Register a per-session notify callback used by ``clarify_callback``."""
|
||||
with _lock:
|
||||
_notify_cbs[session_key] = cb
|
||||
|
||||
|
||||
def unregister_notify(session_key: str) -> None:
|
||||
"""Drop the per-session notify callback and cancel any pending clarify entries."""
|
||||
with _lock:
|
||||
_notify_cbs.pop(session_key, None)
|
||||
# Cancel any pending entries so blocked threads unwind when the run
|
||||
# ends (interrupt, completion, gateway shutdown).
|
||||
clear_session(session_key)
|
||||
|
||||
|
||||
def get_notify(session_key: str) -> Optional[Callable[[_ClarifyEntry], None]]:
|
||||
with _lock:
|
||||
return _notify_cbs.get(session_key)
|
||||
@@ -51,6 +51,13 @@ class DaytonaEnvironment(BaseEnvironment):
|
||||
requested_cwd = cwd
|
||||
super().__init__(cwd=cwd, timeout=timeout)
|
||||
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure
|
||||
_lazy_ensure("terminal.daytona", prompt=False)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
raise ImportError(str(e))
|
||||
from daytona import (
|
||||
Daytona,
|
||||
CreateSandboxFromImageParams,
|
||||
@@ -94,9 +101,13 @@ class DaytonaEnvironment(BaseEnvironment):
|
||||
|
||||
if self._sandbox is None:
|
||||
try:
|
||||
page = self._daytona.list(labels=labels, page=1, limit=1)
|
||||
if page.items:
|
||||
self._sandbox = page.items[0]
|
||||
# Daytona SDK >=0.108.0 uses cursor-based pagination and
|
||||
# list() returns an iterator. Offset-based pagination
|
||||
# (page=1) is removed on June 10, 2026.
|
||||
results = self._daytona.list(labels=labels, limit=1)
|
||||
legacy = next(iter(results), None)
|
||||
if legacy is not None:
|
||||
self._sandbox = legacy
|
||||
self._sandbox.start()
|
||||
logger.info("Daytona: resumed legacy sandbox %s for task %s",
|
||||
self._sandbox.id, task_id)
|
||||
|
||||
@@ -80,11 +80,23 @@ def _delete_direct_snapshot(task_id: str, snapshot_id: str | None = None) -> Non
|
||||
_save_snapshots(snapshots)
|
||||
|
||||
|
||||
def _ensure_modal_sdk() -> None:
|
||||
"""Lazy-install modal on demand. Idempotent — fast no-op once installed."""
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure
|
||||
_lazy_ensure("terminal.modal", prompt=False)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
raise ImportError(str(e))
|
||||
|
||||
|
||||
def _resolve_modal_image(image_spec: Any) -> Any:
|
||||
"""Convert registry references or snapshot ids into Modal image objects.
|
||||
|
||||
Includes add_python support for ubuntu/debian images (absorbed from PR 4511).
|
||||
"""
|
||||
_ensure_modal_sdk()
|
||||
import modal as _modal
|
||||
|
||||
if not isinstance(image_spec, str):
|
||||
@@ -183,6 +195,7 @@ class ModalEnvironment(BaseEnvironment):
|
||||
if restored_snapshot_id:
|
||||
logger.info("Modal: restoring from snapshot %s", restored_snapshot_id[:20])
|
||||
|
||||
_ensure_modal_sdk()
|
||||
import modal as _modal
|
||||
|
||||
cred_mounts = []
|
||||
|
||||
@@ -42,6 +42,19 @@ if TYPE_CHECKING:
|
||||
|
||||
DEFAULT_VERCEL_CWD = "/vercel/sandbox"
|
||||
_DEFAULT_CONTAINER_DISK_MB = 51200
|
||||
|
||||
|
||||
def _ensure_vercel_sdk() -> None:
|
||||
"""Lazy-install vercel SDK on demand. Idempotent."""
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure
|
||||
_lazy_ensure("terminal.vercel", prompt=False)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
raise ImportError(str(e))
|
||||
|
||||
|
||||
_CREATE_RETRY_ATTEMPTS = 3
|
||||
_WRITE_RETRY_ATTEMPTS = 3
|
||||
_TRANSIENT_STATUS_CODES = frozenset({408, 425, 429, 500, 502, 503, 504})
|
||||
@@ -194,6 +207,7 @@ def _extract_snapshot_id(snapshot: Any) -> str | None:
|
||||
|
||||
@cache
|
||||
def _sandbox_status_type() -> type[SandboxStatus]:
|
||||
_ensure_vercel_sdk()
|
||||
from vercel.sandbox import SandboxStatus
|
||||
|
||||
return SandboxStatus
|
||||
@@ -260,6 +274,7 @@ class VercelSandboxEnvironment(BaseEnvironment):
|
||||
"Use the default shared setting."
|
||||
)
|
||||
|
||||
_ensure_vercel_sdk()
|
||||
from vercel.sandbox import Resources
|
||||
|
||||
sandbox_timeout = max(
|
||||
@@ -281,6 +296,7 @@ class VercelSandboxEnvironment(BaseEnvironment):
|
||||
)
|
||||
|
||||
def _create_sandbox(self) -> Sandbox:
|
||||
_ensure_vercel_sdk()
|
||||
from vercel.sandbox import Sandbox
|
||||
|
||||
snapshot_id = _get_snapshot_id(self._task_id) if self._persistent else None
|
||||
|
||||
+209
-17
@@ -120,6 +120,13 @@ class WriteResult:
|
||||
bytes_written: int = 0
|
||||
dirs_created: bool = False
|
||||
lint: Optional[Dict[str, Any]] = None
|
||||
# Semantic diagnostics from the LSP layer, when applicable. Kept in
|
||||
# its own field (not folded into ``lint``) so the model and any
|
||||
# downstream parsers can read syntax errors and semantic errors as
|
||||
# separate signals. ``None`` when LSP is disabled, when the file
|
||||
# isn't in a git workspace, or when no diagnostics were introduced
|
||||
# by this edit.
|
||||
lsp_diagnostics: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
warning: Optional[str] = None
|
||||
|
||||
@@ -136,6 +143,8 @@ class PatchResult:
|
||||
files_created: List[str] = field(default_factory=list)
|
||||
files_deleted: List[str] = field(default_factory=list)
|
||||
lint: Optional[Dict[str, Any]] = None
|
||||
# See :class:`WriteResult.lsp_diagnostics`.
|
||||
lsp_diagnostics: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
@@ -150,6 +159,8 @@ class PatchResult:
|
||||
result["files_deleted"] = self.files_deleted
|
||||
if self.lint:
|
||||
result["lint"] = self.lint
|
||||
if self.lsp_diagnostics:
|
||||
result["lsp_diagnostics"] = self.lsp_diagnostics
|
||||
if self.error:
|
||||
result["error"] = self.error
|
||||
return result
|
||||
@@ -316,6 +327,55 @@ LINTERS = {
|
||||
}
|
||||
|
||||
|
||||
# Patterns that indicate the linter base command exists on PATH but
|
||||
# couldn't actually run — e.g. ``npx tsc`` when tsc isn't installed in
|
||||
# node_modules, or rustfmt complaining there's no Cargo project. When
|
||||
# any of these substrings appears in the linter output, ``_check_lint``
|
||||
# returns ``skipped`` instead of ``error`` so:
|
||||
#
|
||||
# 1. The write isn't flagged for a tooling problem the agent can't fix.
|
||||
# 2. The LSP semantic tier still runs (it gates on success/skipped).
|
||||
#
|
||||
# Patterns are matched case-insensitively against linter stdout.
|
||||
_LINTER_UNUSABLE_PATTERNS = {
|
||||
'npx': (
|
||||
# npx prints this banner when the package isn't installed locally
|
||||
# AND it can't auto-install (no internet, registry off, etc.) or
|
||||
# when the binary it tried to run is the wrong one.
|
||||
'this is not the tsc command you are looking for',
|
||||
# npx with --no-install resolution failures
|
||||
'could not determine executable to run',
|
||||
'not found in npm registry',
|
||||
),
|
||||
'rustfmt': (
|
||||
# rustfmt outside a Cargo project
|
||||
'no input filename given',
|
||||
'error: not a workspace',
|
||||
),
|
||||
'go': (
|
||||
# ``go vet`` on a file outside a module / GOPATH
|
||||
'cannot find package',
|
||||
'go: cannot find main module',
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _looks_like_linter_unusable(base_cmd: str, output: str) -> bool:
|
||||
"""Return True iff ``output`` from ``base_cmd`` indicates the linter
|
||||
itself couldn't run (a tooling gap), as opposed to a real lint error
|
||||
in the file being checked.
|
||||
|
||||
``base_cmd`` is the first word of the linter command line (``npx``,
|
||||
``rustfmt``, ``go``, ...). ``output`` is the stdout/stderr captured
|
||||
from running it.
|
||||
"""
|
||||
patterns = _LINTER_UNUSABLE_PATTERNS.get(base_cmd)
|
||||
if not patterns:
|
||||
return False
|
||||
lower = output.lower()
|
||||
return any(p in lower for p in patterns)
|
||||
|
||||
|
||||
def _lint_json_inproc(content: str) -> tuple[bool, str]:
|
||||
"""In-process JSON syntax check. Returns (ok, error_message)."""
|
||||
import json as _json
|
||||
@@ -867,6 +927,13 @@ class ShellFileOperations(FileOperations):
|
||||
if read_result.exit_code == 0 and read_result.stdout:
|
||||
pre_content = read_result.stdout
|
||||
|
||||
# Snapshot LSP diagnostics for this file (best-effort) so the
|
||||
# post-write LSP layer can return only diagnostics introduced
|
||||
# by this specific edit. Mirrors claude-code's
|
||||
# ``beforeFileEdited`` pattern but wired to the local LSP
|
||||
# rather than an external IDE.
|
||||
self._snapshot_lsp_baseline(path)
|
||||
|
||||
# Create parent directories
|
||||
parent = os.path.dirname(path)
|
||||
dirs_created = False
|
||||
@@ -897,10 +964,21 @@ class ShellFileOperations(FileOperations):
|
||||
# Post-write lint with delta refinement.
|
||||
lint_result = self._check_lint_delta(path, pre_content=pre_content, post_content=content)
|
||||
|
||||
# Semantic diagnostics from the LSP layer — separate channel.
|
||||
# Only fired when the syntax tier reported clean (no point asking
|
||||
# an LSP for a file that won't even parse). Best-effort:
|
||||
# ``""`` is returned for any failure path.
|
||||
lsp_diagnostics: Optional[str] = None
|
||||
if lint_result.success or lint_result.skipped:
|
||||
block = self._maybe_lsp_diagnostics(path)
|
||||
if block:
|
||||
lsp_diagnostics = block
|
||||
|
||||
return WriteResult(
|
||||
bytes_written=bytes_written,
|
||||
dirs_created=dirs_created,
|
||||
lint=lint_result.to_dict() if lint_result else None,
|
||||
lsp_diagnostics=lsp_diagnostics,
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
@@ -996,7 +1074,14 @@ class ShellFileOperations(FileOperations):
|
||||
success=True,
|
||||
diff=diff,
|
||||
files_modified=[path],
|
||||
lint=lint_result.to_dict() if lint_result else None
|
||||
lint=lint_result.to_dict() if lint_result else None,
|
||||
# Propagate the LSP diagnostics already captured by the
|
||||
# internal ``write_file`` call. Its baseline was the
|
||||
# pre-patch content (taken at the start of write_file via
|
||||
# ``_snapshot_lsp_baseline``) so the delta is correct for
|
||||
# the patch as a whole. Keep the field separate from the
|
||||
# syntax-check ``lint`` so the agent can read both signals.
|
||||
lsp_diagnostics=write_result.lsp_diagnostics,
|
||||
)
|
||||
|
||||
def patch_v4a(self, patch_content: str) -> PatchResult:
|
||||
@@ -1081,6 +1166,24 @@ class ShellFileOperations(FileOperations):
|
||||
cmd = linter_cmd.replace("{file}", self._escape_shell_arg(path))
|
||||
result = self._exec(cmd, timeout=30)
|
||||
|
||||
if result.exit_code != 0 and _looks_like_linter_unusable(base_cmd, result.stdout):
|
||||
# The linter command exists on PATH but couldn't actually run
|
||||
# (e.g. ``npx tsc`` when tsc isn't in node_modules; ``rustfmt
|
||||
# --check`` without a Cargo project). This is a tooling gap,
|
||||
# not a real lint failure — surface it as ``skipped`` so the
|
||||
# write doesn't get flagged AND so the LSP tier still runs.
|
||||
from tools.ansi_strip import strip_ansi
|
||||
cleaned = strip_ansi(result.stdout).strip()
|
||||
# Collapse to a single line — the npx banner is multi-line ASCII.
|
||||
first_line = next(
|
||||
(ln.strip() for ln in cleaned.splitlines() if ln.strip()),
|
||||
cleaned[:120],
|
||||
)
|
||||
return LintResult(
|
||||
skipped=True,
|
||||
message=f"{base_cmd} not usable: {first_line[:200]}",
|
||||
)
|
||||
|
||||
return LintResult(
|
||||
success=result.exit_code == 0,
|
||||
output=result.stdout.strip() if result.stdout.strip() else ""
|
||||
@@ -1089,21 +1192,25 @@ class ShellFileOperations(FileOperations):
|
||||
def _check_lint_delta(self, path: str, pre_content: Optional[str],
|
||||
post_content: Optional[str] = None) -> LintResult:
|
||||
"""
|
||||
Run post-write lint with pre-write baseline comparison.
|
||||
Run post-write syntax lint with pre-write baseline comparison.
|
||||
|
||||
Strategy (post-first, pre-lazy):
|
||||
1. Lint the post-write state. If clean → return clean immediately.
|
||||
This is the hot path and matches _check_lint() in cost.
|
||||
2. If post-lint found errors AND we have pre-write content, lint
|
||||
that too. If the pre-write file was already broken, return only
|
||||
the *new* errors introduced by this edit — errors that existed
|
||||
before aren't the agent's problem to chase right now.
|
||||
3. If pre_content is None (new file or unavailable), skip the delta
|
||||
step and return all post-write errors.
|
||||
Two-tier strategy:
|
||||
|
||||
This mirrors Cline's and OpenCode's post-edit LSP pattern: surface
|
||||
only the errors this specific edit introduced, so the agent doesn't
|
||||
get distracted by pre-existing problems.
|
||||
1. **Syntax check** (in-process or shell-based, microseconds).
|
||||
Catches the bug class that motivated this layer: corrupt
|
||||
writes, mashed quotes, truncated output. Hot path.
|
||||
|
||||
2. **Delta refinement against pre-write content** when the
|
||||
syntax tier reports errors. Filter out errors that already
|
||||
existed pre-edit so the agent isn't distracted by inherited
|
||||
state.
|
||||
|
||||
Semantic diagnostics from the LSP layer are fetched separately
|
||||
via :meth:`_maybe_lsp_diagnostics` and surfaced in the
|
||||
``lsp_diagnostics`` field on :class:`WriteResult` /
|
||||
:class:`PatchResult`. Keeping the two channels separate lets
|
||||
the agent (and any downstream parsers) read syntax errors and
|
||||
semantic errors as independent signals.
|
||||
|
||||
Args:
|
||||
path: File path (for linter selection).
|
||||
@@ -1122,12 +1229,12 @@ class ShellFileOperations(FileOperations):
|
||||
"""
|
||||
post = self._check_lint(path, content=post_content)
|
||||
|
||||
# Hot path: clean post-write, no pre-lint needed.
|
||||
# Hot path: clean post-write syntactically.
|
||||
if post.success or post.skipped:
|
||||
return post
|
||||
|
||||
# Post-write has errors. If we have pre-content, run the delta
|
||||
# refinement to filter out pre-existing errors.
|
||||
# Post-write has syntax errors. If we have pre-content, run the
|
||||
# delta refinement to filter out pre-existing errors.
|
||||
if pre_content is None:
|
||||
return post
|
||||
|
||||
@@ -1166,6 +1273,91 @@ class ShellFileOperations(FileOperations):
|
||||
"(pre-existing errors filtered out):\n" + "\n".join(post_lines)
|
||||
)
|
||||
)
|
||||
|
||||
def _lsp_local_only(self) -> bool:
|
||||
"""Return True iff this FileOperations is wired to a local backend.
|
||||
|
||||
LSP servers run on the host process — they need access to the
|
||||
files they're linting. Remote/sandboxed backends (Docker,
|
||||
Modal, SSH, Daytona) keep files inside the sandbox where the
|
||||
host-side LSP server can't reach them, so we skip the LSP
|
||||
path for those entirely.
|
||||
"""
|
||||
env = getattr(self, "env", None)
|
||||
if env is None:
|
||||
# Defensive: some tests construct ShellFileOperations via
|
||||
# ``__new__`` without going through ``__init__``, so
|
||||
# ``self.env`` may be missing. No env = no LSP path.
|
||||
return False
|
||||
try:
|
||||
from tools.environments.local import LocalEnvironment
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
return isinstance(env, LocalEnvironment)
|
||||
|
||||
def _snapshot_lsp_baseline(self, path: str) -> None:
|
||||
"""Capture pre-edit LSP diagnostics so the post-write delta is correct.
|
||||
|
||||
Best-effort. Silent on every failure path — LSP is an
|
||||
enrichment layer and must never break a write.
|
||||
|
||||
Skipped entirely on non-local backends (Docker, Modal, SSH,
|
||||
etc.) — the server can't see files inside the sandbox.
|
||||
"""
|
||||
if not self._lsp_local_only():
|
||||
return
|
||||
try:
|
||||
from agent.lsp import get_service
|
||||
svc = get_service()
|
||||
except Exception: # noqa: BLE001
|
||||
return
|
||||
if svc is None:
|
||||
return
|
||||
try:
|
||||
svc.snapshot_baseline(path)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def _maybe_lsp_diagnostics(self, path: str) -> str:
|
||||
"""Best-effort LSP semantic diagnostics for ``path``.
|
||||
|
||||
Returns a formatted ``<diagnostics>`` block, or empty string
|
||||
when LSP is unavailable / disabled / produced no errors.
|
||||
|
||||
Wraps everything in a try/except so a misbehaving LSP server
|
||||
can't break a write. This intentionally swallows all errors
|
||||
— the calling tier already returned a clean syntax result, so
|
||||
``""`` here just means "no extra info to add".
|
||||
|
||||
Skipped entirely on non-local backends (Docker, Modal, SSH,
|
||||
etc.) — same reasoning as ``_snapshot_lsp_baseline``.
|
||||
"""
|
||||
if not self._lsp_local_only():
|
||||
return ""
|
||||
try:
|
||||
from agent.lsp import get_service
|
||||
except Exception: # noqa: BLE001
|
||||
return ""
|
||||
try:
|
||||
svc = get_service()
|
||||
except Exception: # noqa: BLE001
|
||||
return ""
|
||||
if svc is None or not svc.enabled_for(path):
|
||||
return ""
|
||||
try:
|
||||
diagnostics = svc.get_diagnostics_sync(path, delta=True)
|
||||
except Exception: # noqa: BLE001
|
||||
return ""
|
||||
if not diagnostics:
|
||||
return ""
|
||||
try:
|
||||
from agent.lsp.reporter import report_for_file, truncate
|
||||
block = report_for_file(path, diagnostics)
|
||||
if not block:
|
||||
return ""
|
||||
return truncate("LSP diagnostics introduced by this edit:\n" + block)
|
||||
except Exception: # noqa: BLE001
|
||||
return ""
|
||||
|
||||
# =========================================================================
|
||||
# SEARCH Implementation
|
||||
|
||||
@@ -52,6 +52,13 @@ def _load_fal_client() -> Any:
|
||||
global fal_client
|
||||
if fal_client is not None:
|
||||
return fal_client
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure
|
||||
_lazy_ensure("image.fal", prompt=False)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
raise ImportError(str(e))
|
||||
import fal_client as _fal_client # noqa: F811 — module-global rebind
|
||||
fal_client = _fal_client
|
||||
return fal_client
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
"""
|
||||
Lazy dependency installer for opt-in Hermes Agent backends.
|
||||
|
||||
Many Hermes features (Mistral TTS, ElevenLabs TTS, Honcho memory, Bedrock,
|
||||
Slack, Matrix, etc.) require Python packages that not every user needs. The
|
||||
historical approach was to bundle them all under ``pyproject.toml`` extras
|
||||
(``hermes-agent[all]``) and install them eagerly at setup time. That has
|
||||
two problems:
|
||||
|
||||
1. **Fragility.** When one extra's transitive dependency becomes
|
||||
unavailable on PyPI (quarantined for malware, yanked, broken upload),
|
||||
the *entire* ``[all]`` resolve fails and fresh installs silently fall
|
||||
back to a stripped tier — losing 10+ unrelated extras at once.
|
||||
|
||||
2. **Bloat.** A user who only ever talks to one provider pulls hundreds
|
||||
of packages they will never import.
|
||||
|
||||
The lazy-install pattern fixes both. Backends call :func:`ensure` at the
|
||||
top of their first-import path. If the deps are missing, ``ensure`` checks
|
||||
the ``security.allow_lazy_installs`` config flag (default true) and runs
|
||||
a venv-scoped pip install. If the user has explicitly disabled lazy
|
||||
installs, ``ensure`` raises :class:`FeatureUnavailable` with a clear
|
||||
remediation hint pointing at ``hermes tools`` or the manual pip command.
|
||||
|
||||
Security model:
|
||||
|
||||
* **Venv-scoped only.** Installs target ``sys.executable`` in the active
|
||||
venv. We never touch the system Python.
|
||||
* **PyPI by package name only.** Specs may be ``"package>=1.0,<2"`` etc.
|
||||
We do NOT support ``--index-url`` overrides, ``git+https://``, file:
|
||||
paths, or any other input that could be hijacked by a malicious config.
|
||||
* **Allowlist.** Only specs that appear in :data:`LAZY_DEPS` can be
|
||||
installed via this path. A typo in feature name doesn't get the user
|
||||
install-anything semantics.
|
||||
* **Opt-out.** Setting ``security.allow_lazy_installs: false`` in
|
||||
``config.yaml`` disables runtime installs. Users in restricted networks
|
||||
or strict security postures can pin themselves to whatever was installed
|
||||
at setup time.
|
||||
* **Offline detection.** If the install fails (offline, mirror down,
|
||||
PyPI 404 / quarantine), we surface the failure as
|
||||
:class:`FeatureUnavailable` with the actual pip stderr — no silent
|
||||
retries, no caching of bad state.
|
||||
|
||||
Adding a new backend:
|
||||
|
||||
1. Add an entry to :data:`LAZY_DEPS` with the package specs.
|
||||
2. At the top of the backend module's import path, call
|
||||
``ensure("feature.name")`` inside a try/except that converts
|
||||
:class:`FeatureUnavailable` to a useful runtime error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Allowlist of lazy-installable backends.
|
||||
#
|
||||
# Keys are dot-separated feature names ("namespace.backend"). Values are
|
||||
# tuples of pip-installable specs that match the corresponding extra in
|
||||
# pyproject.toml. The framework enforces that only specs from this map
|
||||
# can flow into the pip install command.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
LAZY_DEPS: dict[str, tuple[str, ...]] = {
|
||||
# ─── Inference providers ───────────────────────────────────────────────
|
||||
# Native Anthropic SDK — needed when provider=anthropic (not via
|
||||
# OpenRouter / aggregators which use the openai SDK).
|
||||
"provider.anthropic": ("anthropic==0.86.0",),
|
||||
# AWS Bedrock provider
|
||||
"provider.bedrock": ("boto3==1.42.89",),
|
||||
|
||||
# ─── Web search backends ───────────────────────────────────────────────
|
||||
"search.exa": ("exa-py==2.10.2",),
|
||||
"search.firecrawl": ("firecrawl-py==4.17.0",),
|
||||
"search.parallel": ("parallel-web==0.4.2",),
|
||||
|
||||
# ─── TTS providers ─────────────────────────────────────────────────────
|
||||
# Pinned to exact versions to match pyproject.toml's no-ranges policy
|
||||
# (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.
|
||||
"tts.edge": ("edge-tts==7.2.7",),
|
||||
"tts.elevenlabs": ("elevenlabs==1.59.0",),
|
||||
|
||||
# ─── Speech-to-text providers ──────────────────────────────────────────
|
||||
"stt.faster_whisper": (
|
||||
"faster-whisper==1.2.1",
|
||||
"sounddevice==0.5.5",
|
||||
"numpy==2.4.3",
|
||||
),
|
||||
|
||||
# ─── Image generation backends ─────────────────────────────────────────
|
||||
"image.fal": ("fal-client==0.13.1",),
|
||||
|
||||
# ─── Memory providers ──────────────────────────────────────────────────
|
||||
"memory.honcho": ("honcho-ai==2.0.1",),
|
||||
"memory.hindsight": ("hindsight-client==0.6.1",),
|
||||
|
||||
# ─── Messaging platforms (lazy-installable on demand) ──────────────────
|
||||
"platform.telegram": ("python-telegram-bot[webhooks]==22.6",),
|
||||
"platform.discord": ("discord.py[voice]==2.7.1",),
|
||||
"platform.slack": (
|
||||
"slack-bolt==1.27.0",
|
||||
"slack-sdk==3.40.1",
|
||||
),
|
||||
"platform.matrix": (
|
||||
"mautrix[encryption]==0.21.0",
|
||||
"Markdown==3.10.2",
|
||||
"aiosqlite==0.22.1",
|
||||
"asyncpg==0.31.0",
|
||||
"aiohttp-socks==0.11.0",
|
||||
),
|
||||
"platform.dingtalk": (
|
||||
"dingtalk-stream==0.24.3",
|
||||
"alibabacloud-dingtalk==2.2.42",
|
||||
"qrcode==7.4.2",
|
||||
),
|
||||
"platform.feishu": (
|
||||
"lark-oapi==1.5.3",
|
||||
"qrcode==7.4.2",
|
||||
),
|
||||
|
||||
# ─── Terminal backends ─────────────────────────────────────────────────
|
||||
"terminal.modal": ("modal==1.3.4",),
|
||||
"terminal.daytona": ("daytona==0.155.0",),
|
||||
"terminal.vercel": ("vercel==0.5.7",),
|
||||
|
||||
# ─── Skills ────────────────────────────────────────────────────────────
|
||||
"skill.google_workspace": (
|
||||
"google-api-python-client==2.194.0",
|
||||
"google-auth-oauthlib==1.3.1",
|
||||
"google-auth-httplib2==0.3.1",
|
||||
),
|
||||
"skill.youtube": ("youtube-transcript-api==1.2.4",),
|
||||
|
||||
# ─── Tools ─────────────────────────────────────────────────────────────
|
||||
# ACP adapter (VS Code / Zed / JetBrains integration)
|
||||
"tool.acp": ("agent-client-protocol==0.9.0",),
|
||||
# Dashboard (`hermes dashboard`)
|
||||
"tool.dashboard": (
|
||||
"fastapi==0.133.1",
|
||||
"uvicorn[standard]==0.41.0",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# Conservative regex for spec validation — package name plus optional
|
||||
# version range. Reject anything that looks like a URL, file path, or shell
|
||||
# metacharacter.
|
||||
_SAFE_SPEC = re.compile(
|
||||
r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*" # package name
|
||||
r"(?:\[[A-Za-z0-9_,\-]+\])?" # optional [extras]
|
||||
r"(?:[<>=!~]=?[A-Za-z0-9_.\-+,*<>=!~]+)?" # optional version specifier
|
||||
r"$"
|
||||
)
|
||||
|
||||
|
||||
class FeatureUnavailable(RuntimeError):
|
||||
"""A lazily-installable feature is missing and cannot be made available.
|
||||
|
||||
Either the deps were never installed and the user has disabled lazy
|
||||
installs, or the install attempt failed.
|
||||
"""
|
||||
|
||||
def __init__(self, feature: str, missing: tuple[str, ...], reason: str):
|
||||
self.feature = feature
|
||||
self.missing = missing
|
||||
self.reason = reason
|
||||
super().__init__(self._format())
|
||||
|
||||
def _format(self) -> str:
|
||||
spec_list = " ".join(repr(s) for s in self.missing)
|
||||
return (
|
||||
f"Feature {self.feature!r} unavailable: {self.reason}. "
|
||||
f"To enable manually: uv pip install {spec_list} "
|
||||
f"(or: pip install {spec_list})."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _InstallResult:
|
||||
success: bool
|
||||
stdout: str
|
||||
stderr: str
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Internals
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _allow_lazy_installs() -> bool:
|
||||
"""Return the ``security.allow_lazy_installs`` config flag.
|
||||
|
||||
Defaults to True. If config is unreadable we fail open (allow), because
|
||||
refusing to install would lock people out of their own backends; the
|
||||
decision to block is an explicit user opt-in.
|
||||
"""
|
||||
if os.environ.get("HERMES_DISABLE_LAZY_INSTALLS") == "1":
|
||||
return False
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
except Exception:
|
||||
return True
|
||||
sec = cfg.get("security") or {}
|
||||
val = sec.get("allow_lazy_installs", True)
|
||||
return bool(val)
|
||||
|
||||
|
||||
def _spec_is_safe(spec: str) -> bool:
|
||||
"""Reject pip specs that contain URLs, paths, or shell metacharacters."""
|
||||
if not spec or len(spec) > 200:
|
||||
return False
|
||||
if any(ch in spec for ch in (";", "|", "&", "`", "$", "\n", "\r", "\t", "\\")):
|
||||
return False
|
||||
if spec.startswith(("-", "/", ".")) or "://" in spec or "@" in spec:
|
||||
return False
|
||||
return bool(_SAFE_SPEC.match(spec))
|
||||
|
||||
|
||||
def _pkg_name_from_spec(spec: str) -> str:
|
||||
"""Extract the bare package name from a pip spec.
|
||||
|
||||
``"slack-bolt>=1.18.0,<2"`` → ``"slack-bolt"``
|
||||
``"mautrix[encryption]>=0.20"`` → ``"mautrix"``
|
||||
"""
|
||||
m = re.match(r"^([A-Za-z0-9_][A-Za-z0-9_.\-]*)", spec)
|
||||
return m.group(1) if m else spec
|
||||
|
||||
|
||||
def _is_satisfied(spec: str) -> bool:
|
||||
"""Best-effort check: is ``spec`` already satisfied in the current env?
|
||||
|
||||
We don't enforce the version range — if the package is importable
|
||||
we assume the user knows what they're doing. This matches how the
|
||||
lazy-import sites already behave.
|
||||
"""
|
||||
pkg = _pkg_name_from_spec(spec)
|
||||
try:
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
except ImportError:
|
||||
return False
|
||||
try:
|
||||
version(pkg)
|
||||
return True
|
||||
except PackageNotFoundError:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _InstallResult:
|
||||
"""Install ``specs`` into the active venv using uv → pip → ensurepip ladder.
|
||||
|
||||
Mirrors the strategy in ``hermes_cli.tools_config._pip_install`` but
|
||||
kept independent here so this module has no CLI dependency.
|
||||
"""
|
||||
if not specs:
|
||||
return _InstallResult(True, "", "")
|
||||
|
||||
venv_root = Path(sys.executable).parent.parent
|
||||
uv_env = {**os.environ, "VIRTUAL_ENV": str(venv_root)}
|
||||
|
||||
# Tier 1: uv (preferred — fast, doesn't need pip in the venv)
|
||||
uv_bin = shutil.which("uv")
|
||||
if uv_bin:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[uv_bin, "pip", "install", *specs],
|
||||
capture_output=True, text=True, timeout=timeout, env=uv_env,
|
||||
)
|
||||
if r.returncode == 0:
|
||||
return _InstallResult(True, r.stdout or "", r.stderr or "")
|
||||
logger.debug("uv pip install failed: %s", r.stderr)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
|
||||
logger.debug("uv invocation failed: %s", e)
|
||||
|
||||
# Tier 2: python -m pip (with ensurepip bootstrap if needed)
|
||||
pip_cmd = [sys.executable, "-m", "pip"]
|
||||
try:
|
||||
probe = subprocess.run(
|
||||
pip_cmd + ["--version"],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
if probe.returncode != 0:
|
||||
raise FileNotFoundError("pip not in venv")
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
try:
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
|
||||
capture_output=True, text=True, timeout=120, check=True,
|
||||
)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
||||
return _InstallResult(False, "",
|
||||
f"pip not available and ensurepip failed: {e}")
|
||||
|
||||
try:
|
||||
r = subprocess.run(
|
||||
pip_cmd + ["install", *specs],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
)
|
||||
return _InstallResult(r.returncode == 0, r.stdout or "", r.stderr or "")
|
||||
except subprocess.TimeoutExpired as e:
|
||||
return _InstallResult(False, "", f"pip install timed out: {e}")
|
||||
except Exception as e:
|
||||
return _InstallResult(False, "", f"pip install failed: {e}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Public API
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def feature_specs(feature: str) -> tuple[str, ...]:
|
||||
"""Return the registered specs for a feature, or raise KeyError."""
|
||||
if feature not in LAZY_DEPS:
|
||||
raise KeyError(f"Unknown lazy feature: {feature!r}")
|
||||
return LAZY_DEPS[feature]
|
||||
|
||||
|
||||
def feature_missing(feature: str) -> tuple[str, ...]:
|
||||
"""Return the subset of specs for ``feature`` not currently installed."""
|
||||
return tuple(s for s in feature_specs(feature) if not _is_satisfied(s))
|
||||
|
||||
|
||||
def ensure(feature: str, *, prompt: bool = True) -> None:
|
||||
"""Make sure all packages for ``feature`` are importable.
|
||||
|
||||
If they're missing, attempts to install them in the active venv. Raises
|
||||
:class:`FeatureUnavailable` if the user has disabled lazy installs or
|
||||
if the install attempt fails.
|
||||
|
||||
``prompt``: when True (default) and stdin is a TTY, asks the user to
|
||||
confirm before installing. Non-interactive callers (gateway, cron,
|
||||
batch) get prompt=False and skip the confirmation — config flag is
|
||||
the gate in that case.
|
||||
"""
|
||||
if feature not in LAZY_DEPS:
|
||||
raise FeatureUnavailable(
|
||||
feature, (), f"feature {feature!r} not in LAZY_DEPS allowlist"
|
||||
)
|
||||
|
||||
missing = feature_missing(feature)
|
||||
if not missing:
|
||||
return
|
||||
|
||||
# Validate every spec against the allowlist + safety regex. Belt and
|
||||
# braces — the keys-in-LAZY_DEPS check above already constrains this.
|
||||
for spec in missing:
|
||||
if not _spec_is_safe(spec):
|
||||
raise FeatureUnavailable(
|
||||
feature, missing,
|
||||
f"refusing to install unsafe spec {spec!r}"
|
||||
)
|
||||
|
||||
if not _allow_lazy_installs():
|
||||
raise FeatureUnavailable(
|
||||
feature, missing,
|
||||
"lazy installs disabled (security.allow_lazy_installs=false)"
|
||||
)
|
||||
|
||||
if prompt and sys.stdin.isatty() and sys.stdout.isatty():
|
||||
spec_list = ", ".join(missing)
|
||||
try:
|
||||
answer = input(
|
||||
f"\nFeature {feature!r} requires: {spec_list}\n"
|
||||
f"Install into the active venv now? [Y/n] "
|
||||
).strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
answer = "n"
|
||||
if answer and answer not in ("y", "yes"):
|
||||
raise FeatureUnavailable(
|
||||
feature, missing, "user declined install at prompt"
|
||||
)
|
||||
|
||||
logger.info("Lazy-installing %s for feature %r", " ".join(missing), feature)
|
||||
result = _venv_pip_install(missing)
|
||||
if not result.success:
|
||||
# Surface the actual pip error so the user can debug PyPI-side
|
||||
# issues (404 quarantine, network down, etc.).
|
||||
snippet = (result.stderr or result.stdout or "").strip()
|
||||
if snippet:
|
||||
# Clip to a readable size — pip can dump pages of resolution traces.
|
||||
snippet = snippet[-2000:]
|
||||
raise FeatureUnavailable(
|
||||
feature, missing,
|
||||
f"pip install failed: {snippet or 'no error output'}"
|
||||
)
|
||||
|
||||
# Verify post-install. importlib.metadata caches per-process, so if we
|
||||
# just installed something the cache may not see it without a refresh.
|
||||
try:
|
||||
import importlib.metadata as _md
|
||||
if hasattr(_md, "_cache_clear"):
|
||||
_md._cache_clear() # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
still_missing = feature_missing(feature)
|
||||
if still_missing:
|
||||
raise FeatureUnavailable(
|
||||
feature, still_missing,
|
||||
"install reported success but packages still not importable "
|
||||
"(may require Python restart)"
|
||||
)
|
||||
|
||||
logger.info("Lazy install complete for feature %r", feature)
|
||||
|
||||
|
||||
def is_available(feature: str) -> bool:
|
||||
"""Return True if the feature's deps are already satisfied."""
|
||||
if feature not in LAZY_DEPS:
|
||||
return False
|
||||
return not feature_missing(feature)
|
||||
|
||||
|
||||
def feature_install_command(feature: str) -> Optional[str]:
|
||||
"""Return the ``pip install`` command a user could run manually, or None."""
|
||||
if feature not in LAZY_DEPS:
|
||||
return None
|
||||
specs = LAZY_DEPS[feature]
|
||||
return "uv pip install " + " ".join(repr(s) for s in specs)
|
||||
@@ -355,6 +355,9 @@ def _parse_target_ref(platform_name: str, target_ref: str):
|
||||
# Matrix room IDs (start with !) and user IDs (start with @) are explicit
|
||||
if platform_name == "matrix" and (target_ref.startswith("!") or target_ref.startswith("@")):
|
||||
return target_ref, None, True
|
||||
# XMPP JIDs (user@server or room@conference.server) are explicit
|
||||
if platform_name == "xmpp" and "@" in target_ref:
|
||||
return target_ref, None, True
|
||||
return None, None, False
|
||||
|
||||
|
||||
|
||||
@@ -255,11 +255,16 @@ def _get_provider(stt_config: dict) -> str:
|
||||
return "none"
|
||||
|
||||
if provider == "mistral":
|
||||
if _HAS_MISTRAL and get_env_value("MISTRAL_API_KEY"):
|
||||
return "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.
|
||||
logger.warning(
|
||||
"STT provider 'mistral' configured but mistralai package "
|
||||
"not installed or MISTRAL_API_KEY not set"
|
||||
"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."
|
||||
)
|
||||
return "none"
|
||||
|
||||
@@ -281,7 +286,9 @@ def _get_provider(stt_config: dict) -> str:
|
||||
|
||||
return provider # Unknown — let it fail downstream
|
||||
|
||||
# --- Auto-detect (no explicit provider): local > groq > openai > mistral > xai > elevenlabs -
|
||||
# --- Auto-detect (no explicit provider): local > groq > openai > xai > elevenlabs -
|
||||
# mistral is intentionally skipped while `mistralai` is quarantined on
|
||||
# PyPI (malicious 2.4.6 release on 2026-05-12).
|
||||
|
||||
if _HAS_FASTER_WHISPER:
|
||||
return "local"
|
||||
@@ -293,9 +300,6 @@ 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"
|
||||
if _HAS_MISTRAL and get_env_value("MISTRAL_API_KEY"):
|
||||
logger.info("No local STT available, using Mistral Voxtral Transcribe API")
|
||||
return "mistral"
|
||||
if get_env_value("XAI_API_KEY"):
|
||||
logger.info("No local STT available, using xAI Grok STT API")
|
||||
return "xai"
|
||||
|
||||
+39
-11
@@ -80,11 +80,34 @@ from tools.xai_http import hermes_xai_user_agent
|
||||
|
||||
def _import_edge_tts():
|
||||
"""Lazy import edge_tts. Returns the module or raises ImportError."""
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure
|
||||
_lazy_ensure("tts.edge", prompt=False)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
raise ImportError(str(e))
|
||||
import edge_tts
|
||||
return edge_tts
|
||||
|
||||
def _import_elevenlabs():
|
||||
"""Lazy import ElevenLabs client. Returns the class or raises ImportError."""
|
||||
"""Lazy import ElevenLabs client. Returns the class or raises ImportError.
|
||||
|
||||
Calls :func:`tools.lazy_deps.ensure` first so the SDK gets installed on
|
||||
demand if the user picked ElevenLabs as their TTS provider but never ran
|
||||
the post-setup hook (e.g. enabled it by editing config.yaml directly).
|
||||
Raises ``ImportError`` on lazy-install failure so existing callers'
|
||||
error-handling paths keep working.
|
||||
"""
|
||||
try:
|
||||
from tools.lazy_deps import FeatureUnavailable, ensure
|
||||
ensure("tts.elevenlabs", prompt=False)
|
||||
except ImportError:
|
||||
# lazy_deps module itself missing — fall through to the raw import
|
||||
# so older code paths still get a clean ImportError.
|
||||
pass
|
||||
except Exception as e: # FeatureUnavailable or any unexpected error
|
||||
raise ImportError(str(e))
|
||||
from elevenlabs.client import ElevenLabs
|
||||
return ElevenLabs
|
||||
|
||||
@@ -1662,16 +1685,21 @@ def text_to_speech_tool(
|
||||
_generate_xai_tts(text, file_str, tts_config)
|
||||
|
||||
elif provider == "mistral":
|
||||
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)
|
||||
# `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)
|
||||
|
||||
elif provider == "gemini":
|
||||
logger.info("Generating speech with Google Gemini TTS...")
|
||||
|
||||
+31
-1
@@ -64,6 +64,13 @@ def _load_firecrawl_cls() -> type:
|
||||
"""Import and cache ``firecrawl.Firecrawl``."""
|
||||
global _FIRECRAWL_CLS_CACHE
|
||||
if _FIRECRAWL_CLS_CACHE is None:
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure
|
||||
_lazy_ensure("search.firecrawl", prompt=False)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
raise ImportError(str(e))
|
||||
from firecrawl import Firecrawl as _cls
|
||||
_FIRECRAWL_CLS_CACHE = _cls
|
||||
return _FIRECRAWL_CLS_CACHE
|
||||
@@ -358,6 +365,13 @@ def _get_parallel_client():
|
||||
|
||||
Requires PARALLEL_API_KEY environment variable.
|
||||
"""
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure
|
||||
_lazy_ensure("search.parallel", prompt=False)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
raise ImportError(str(e))
|
||||
from parallel import Parallel
|
||||
global _parallel_client
|
||||
if _parallel_client is None:
|
||||
@@ -376,6 +390,13 @@ def _get_async_parallel_client():
|
||||
|
||||
Requires PARALLEL_API_KEY environment variable.
|
||||
"""
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure
|
||||
_lazy_ensure("search.parallel", prompt=False)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
raise ImportError(str(e))
|
||||
from parallel import AsyncParallel
|
||||
global _async_parallel_client
|
||||
if _async_parallel_client is None:
|
||||
@@ -408,7 +429,9 @@ def _tavily_request(endpoint: str, payload: dict) -> dict:
|
||||
payload["api_key"] = api_key
|
||||
url = f"{_TAVILY_BASE_URL}/{endpoint.lstrip('/')}"
|
||||
logger.info("Tavily %s request to %s", endpoint, url)
|
||||
response = httpx.post(url, json=payload, timeout=60)
|
||||
# Tavily /crawl requires Bearer auth in header (body-only auth returns 401)
|
||||
headers = {"Authorization": f"Bearer {api_key}"} if endpoint.strip("/") == "crawl" else {}
|
||||
response = httpx.post(url, json=payload, headers=headers, timeout=60)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
@@ -990,6 +1013,13 @@ def _get_exa_client():
|
||||
|
||||
Requires EXA_API_KEY environment variable.
|
||||
"""
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure
|
||||
_lazy_ensure("search.exa", prompt=False)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
raise ImportError(str(e))
|
||||
from exa_py import Exa
|
||||
global _exa_client
|
||||
if _exa_client is None:
|
||||
|
||||
Reference in New Issue
Block a user