Merge branch 'main' into bb/gui

This commit is contained in:
emozilla
2026-05-12 21:18:07 -04:00
139 changed files with 13565 additions and 816 deletions
+105 -9
View File
@@ -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
+278
View File
@@ -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)
+14 -3
View File
@@ -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)
+13
View File
@@ -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 = []
+16
View File
@@ -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
View File
@@ -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
+7
View File
@@ -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
+441
View File
@@ -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)
+3
View File
@@ -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
+12 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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: