Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions hermes_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import os
from pathlib import Path
from typing import Optional


def get_hermes_home() -> Path:
Expand All @@ -17,7 +18,7 @@ def get_hermes_home() -> Path:
return Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))


def get_optional_skills_dir(default: Path | None = None) -> Path:
def get_optional_skills_dir(default: Optional[Path] = None) -> Path:
"""Return the optional-skills directory, honoring package-manager wrappers.

Packaged installs may ship ``optional-skills`` outside the Python package
Expand Down Expand Up @@ -75,7 +76,7 @@ def display_hermes_home() -> str:
VALID_REASONING_EFFORTS = ("xhigh", "high", "medium", "low", "minimal")


def parse_reasoning_effort(effort: str) -> dict | None:
def parse_reasoning_effort(effort: str) -> Optional[dict]:
"""Parse a reasoning effort level into a config dict.

Valid levels: "xhigh", "high", "medium", "low", "minimal", "none".
Expand Down
1 change: 1 addition & 0 deletions plugins/memory/hindsight/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from typing import Any, Dict, List

from agent.memory_provider import MemoryProvider
from hermes_constants import get_hermes_home
from tools.registry import tool_error

logger = logging.getLogger(__name__)
Expand Down
48 changes: 40 additions & 8 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@
# User-managed env files should override stale shell exports on restart.
from hermes_cli.env_loader import load_hermes_dotenv

# Default timeout for detecting stale streams (seconds)
_DEFAULT_STREAM_STALE_TIMEOUT = 180.0

_hermes_home = get_hermes_home()
_project_env = Path(__file__).parent / '.env'
_loaded_env_paths = load_hermes_dotenv(hermes_home=_hermes_home, project_env=_project_env)
Expand Down Expand Up @@ -430,6 +433,27 @@ def base_url(self, value: str) -> None:
self._base_url = value
self._base_url_lower = value.lower() if value else ""

def _is_local_provider(self) -> bool:
"""Detect if provider is local (oMLX, Ollama, etc.) vs cloud API.

Local providers may have long prefill times that shouldn't trigger
stale stream detection.
"""
base_url = str(self._base_url or "").lower()
# Local providers typically use localhost/127.0.0.1 or no URL
local_patterns = [
"localhost",
"127.0.0.1",
"::1", # IPv6 localhost
"0.0.0.0",
"/tmp/", # Unix sockets
"ollama", # Common local setups
"omlx", # oMLX local inference
"mlx", # Apple MLX
]
# Empty base_url typically means using default local provider
return any(p in base_url for p in local_patterns) or not base_url

def __init__(
self,
base_url: str = None,
Expand Down Expand Up @@ -4702,19 +4726,27 @@ def _call():
if request_client is not None:
self._close_request_openai_client(request_client, reason="stream_request_complete")

_stream_stale_timeout_base = float(os.getenv("HERMES_STREAM_STALE_TIMEOUT", 180.0))
_stream_stale_timeout_base = float(os.getenv("HERMES_STREAM_STALE_TIMEOUT", _DEFAULT_STREAM_STALE_TIMEOUT))
# Scale the stale timeout for large contexts: slow models (like Opus)
# can legitimately think for minutes before producing the first token
# when the context is large. Without this, the stale detector kills
# healthy connections during the model's thinking phase, producing
# spurious RemoteProtocolError ("peer closed connection").
_est_tokens = sum(len(str(v)) for v in api_kwargs.get("messages", [])) // 4
if _est_tokens > 100_000:
_stream_stale_timeout = max(_stream_stale_timeout_base, 300.0)
elif _est_tokens > 50_000:
_stream_stale_timeout = max(_stream_stale_timeout_base, 240.0)

# Local providers (oMLX, Ollama, etc.) may take much longer for prefill
# without being "stale". Disable timeout for local providers unless
# explicitly configured via HERMES_STREAM_STALE_TIMEOUT.
if _stream_stale_timeout_base == _DEFAULT_STREAM_STALE_TIMEOUT and self._is_local_provider():
_stream_stale_timeout = float('inf') # No timeout for local providers
logger.debug("Local provider detected, disabling stale stream timeout")
else:
_stream_stale_timeout = _stream_stale_timeout_base
_est_tokens = sum(len(str(v)) for v in api_kwargs.get("messages", [])) // 4
if _est_tokens > 100_000:
_stream_stale_timeout = max(_stream_stale_timeout_base, 300.0)
elif _est_tokens > 50_000:
_stream_stale_timeout = max(_stream_stale_timeout_base, 240.0)
else:
_stream_stale_timeout = _stream_stale_timeout_base

t = threading.Thread(target=_call, daemon=True)
t.start()
Expand All @@ -4725,7 +4757,7 @@ def _call():
# but delivering no real chunks. Kill the client so the
# inner retry loop can start a fresh connection.
_stale_elapsed = time.time() - last_chunk_time["t"]
if _stale_elapsed > _stream_stale_timeout:
if _stream_stale_timeout != float('inf') and _stale_elapsed > _stream_stale_timeout:
_est_ctx = sum(len(str(v)) for v in api_kwargs.get("messages", [])) // 4
logger.warning(
"Stream stale for %.0fs (threshold %.0fs) — no chunks received. "
Expand Down
16 changes: 9 additions & 7 deletions tools/environments/daytona.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,21 +154,23 @@ def _upload_if_changed(self, host_path: str, remote_path: str) -> bool:
return False

def _sync_skills_and_credentials(self) -> None:
"""Upload changed credential files and skill files into the sandbox."""
"""Upload changed credential files into the sandbox.

Note: Skill files are not synced to the sandbox. Skills are loaded
on the host side by skill_view(), build_skills_system_prompt(), and
_load_skill_payload(). Syncing ~445 skill files (890 SDK round-trips)
added ~275s to every session start with no benefit.
"""
container_base = f"{self._remote_home}/.hermes"
try:
from tools.credential_files import get_credential_file_mounts, iter_skills_files
from tools.credential_files import get_credential_file_mounts

for mount_entry in get_credential_file_mounts():
remote_path = mount_entry["container_path"].replace("/root/.hermes", container_base, 1)
if self._upload_if_changed(mount_entry["host_path"], remote_path):
logger.debug("Daytona: synced credential %s", remote_path)

for entry in iter_skills_files(container_base=container_base):
if self._upload_if_changed(entry["host_path"], entry["container_path"]):
logger.debug("Daytona: synced skill %s", entry["container_path"])
except Exception as e:
logger.debug("Daytona: could not sync skills/credentials: %s", e)
logger.debug("Daytona: could not sync credentials: %s", e)

def _ensure_sandbox_ready(self):
"""Restart sandbox if it was stopped (e.g., by a previous interrupt)."""
Expand Down
21 changes: 5 additions & 16 deletions tools/environments/modal.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,6 @@ def __init__(
try:
from tools.credential_files import (
get_credential_file_mounts,
iter_skills_files,
iter_cache_files,
)

Expand All @@ -205,17 +204,9 @@ def __init__(
mount_entry["container_path"],
)

# Mount individual skill files (symlinks filtered out).
skills_files = iter_skills_files()
for entry in skills_files:
cred_mounts.append(
_modal.Mount.from_local_file(
entry["host_path"],
remote_path=entry["container_path"],
)
)
if skills_files:
logger.info("Modal: mounting %d skill files", len(skills_files))
# Note: Skill files are not mounted. Skills are loaded on the host
# side and passed via system prompt. Mounting ~445 skill files
# added significant overhead with no benefit.

# Mount host-side cache files (documents, images, audio,
# screenshots). New files arriving mid-session are picked up
Expand Down Expand Up @@ -336,17 +327,15 @@ def _sync_files(self) -> None:
try:
from tools.credential_files import (
get_credential_file_mounts,
iter_skills_files,
iter_cache_files,
)

for entry in get_credential_file_mounts():
if self._push_file_to_sandbox(entry["host_path"], entry["container_path"]):
logger.debug("Modal: synced credential %s", entry["container_path"])

for entry in iter_skills_files():
if self._push_file_to_sandbox(entry["host_path"], entry["container_path"]):
logger.debug("Modal: synced skill file %s", entry["container_path"])
# Note: Skill files are not synced. Skills are loaded on the host
# side and passed via system prompt.

for entry in iter_cache_files():
if self._push_file_to_sandbox(entry["host_path"], entry["container_path"]):
Expand Down
3 changes: 0 additions & 3 deletions website/docs/user-guide/features/browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,6 @@ browser:

When enabled, Hermes sends a stable profile-scoped identity to Camofox. The Camofox server maps this identity to a persistent browser profile directory, so cookies, logins, and localStorage survive across restarts. Different Hermes profiles get different browser profiles (profile isolation).

:::note
The Camofox server must also be configured with `CAMOFOX_PROFILE_DIR` on the server side for persistence to work.
:::

#### VNC live view

Expand Down