Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 66a636f48a | |||
| 59575d6a91 | |||
| f46542b6c6 | |||
| 5b29ff50f8 | |||
| 7258311710 | |||
| 910ec7eb38 | |||
| 4b45f65858 | |||
| b374f52063 | |||
| bd43a43f07 | |||
| 432ba3b709 | |||
| 712cebc40f | |||
| 45f57c2012 | |||
| 41081d718c | |||
| 281100e2df | |||
| 0d7f739675 | |||
| 9783c9d5c1 | |||
| 0cfc1f88a3 | |||
| 3bc953a666 | |||
| bd6b138e85 | |||
| 9792bde31a | |||
| 9d1e13019e | |||
| 37cabc47d3 | |||
| f7f30aaab9 | |||
| d218cf9118 | |||
| 841401f588 | |||
| 77bcaba2d7 | |||
| e0cfc089da | |||
| 7126524e8d | |||
| f83c27e26f | |||
| ab548a9b5e | |||
| 73e66eb3c0 | |||
| 14cf2d85ca | |||
| 8bb1d15da4 | |||
| 861624d4e9 | |||
| e4033b2baf | |||
| 94e3d9adbf | |||
| 0dcd6ab2f2 | |||
| b6461903ff | |||
| 8f6ef042c1 | |||
| 099dfca6db | |||
| 68ab37e891 | |||
| 65dace1b1a | |||
| 650b400c98 | |||
| 61949f0af7 | |||
| 52c5e491f5 | |||
| f665351740 |
@@ -0,0 +1,40 @@
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
- 'nix/**'
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
- 'hermes_cli/**'
|
||||
- 'run_agent.py'
|
||||
- 'acp_adapter/**'
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
nix:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: DeterminateSystems/nix-installer-action@main
|
||||
- uses: DeterminateSystems/magic-nix-cache-action@main
|
||||
- name: Check flake
|
||||
if: runner.os == 'Linux'
|
||||
run: nix flake check --print-build-logs
|
||||
- name: Build package
|
||||
if: runner.os == 'Linux'
|
||||
run: nix build --print-build-logs
|
||||
- name: Evaluate flake (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
run: nix flake show --json > /dev/null
|
||||
@@ -54,3 +54,7 @@ environments/benchmarks/evals/
|
||||
# Release script temp files
|
||||
.release_notes.md
|
||||
mini-swe-agent/
|
||||
|
||||
# Nix
|
||||
.direnv/
|
||||
result
|
||||
|
||||
@@ -18,6 +18,7 @@ import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
|
||||
def _setup_logging() -> None:
|
||||
@@ -44,7 +45,7 @@ def _load_env() -> None:
|
||||
"""Load .env from HERMES_HOME (default ``~/.hermes``)."""
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
|
||||
hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||
hermes_home = get_hermes_home()
|
||||
loaded = load_hermes_dotenv(hermes_home=hermes_home)
|
||||
if loaded:
|
||||
for env_file in loaded:
|
||||
|
||||
@@ -10,7 +10,7 @@ thread while the event loop lives on the main thread).
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from collections import defaultdict, deque
|
||||
from collections import deque
|
||||
from typing import Any, Callable, Deque, Dict
|
||||
|
||||
import acp
|
||||
|
||||
@@ -5,14 +5,11 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
from concurrent.futures import TimeoutError as FutureTimeout
|
||||
from typing import Any, Callable, Optional
|
||||
from typing import Callable
|
||||
|
||||
from acp.schema import (
|
||||
AllowedOutcome,
|
||||
DeniedOutcome,
|
||||
PermissionOption,
|
||||
RequestPermissionRequest,
|
||||
SelectedPermissionOutcome,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -8,6 +8,8 @@ history.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
@@ -251,7 +253,7 @@ class SessionManager:
|
||||
import os
|
||||
from pathlib import Path
|
||||
from hermes_state import SessionDB
|
||||
hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||
hermes_home = get_hermes_home()
|
||||
self._db_instance = SessionDB(db_path=hermes_home / "state.db")
|
||||
return self._db_instance
|
||||
except Exception:
|
||||
|
||||
+4
-217
@@ -14,6 +14,8 @@ import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
@@ -376,24 +378,12 @@ def resolve_anthropic_token() -> Optional[str]:
|
||||
return preferred
|
||||
return cc_token
|
||||
|
||||
# 3. Hermes-managed OAuth credentials (~/.hermes/.anthropic_oauth.json)
|
||||
hermes_creds = read_hermes_oauth_credentials()
|
||||
if hermes_creds:
|
||||
if is_claude_code_token_valid(hermes_creds):
|
||||
logger.debug("Using Hermes-managed OAuth credentials")
|
||||
return hermes_creds["accessToken"]
|
||||
# Expired — try refresh
|
||||
logger.debug("Hermes OAuth token expired — attempting refresh")
|
||||
refreshed = refresh_hermes_oauth_token()
|
||||
if refreshed:
|
||||
return refreshed
|
||||
|
||||
# 4. Claude Code credential file
|
||||
# 3. Claude Code credential file
|
||||
resolved_claude_token = _resolve_claude_code_token_from_credentials(creds)
|
||||
if resolved_claude_token:
|
||||
return resolved_claude_token
|
||||
|
||||
# 5. Regular API key, or a legacy OAuth token saved in ANTHROPIC_API_KEY.
|
||||
# 4. Regular API key, or a legacy OAuth token saved in ANTHROPIC_API_KEY.
|
||||
# This remains as a compatibility fallback for pre-migration Hermes configs.
|
||||
api_key = os.getenv("ANTHROPIC_API_KEY", "").strip()
|
||||
if api_key:
|
||||
@@ -442,213 +432,10 @@ def run_oauth_setup_token() -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
# ── Hermes-native PKCE OAuth flow ────────────────────────────────────────
|
||||
# Mirrors the flow used by Claude Code, pi-ai, and OpenCode.
|
||||
# Stores credentials in ~/.hermes/.anthropic_oauth.json (our own file).
|
||||
|
||||
_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||
_OAUTH_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
|
||||
_OAUTH_REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"
|
||||
_OAUTH_SCOPES = "org:create_api_key user:profile user:inference"
|
||||
_HERMES_OAUTH_FILE = Path(os.getenv("HERMES_HOME", str(Path.home() / ".hermes"))) / ".anthropic_oauth.json"
|
||||
|
||||
|
||||
def _generate_pkce() -> tuple:
|
||||
"""Generate PKCE code_verifier and code_challenge (S256)."""
|
||||
import base64
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
|
||||
challenge = base64.urlsafe_b64encode(
|
||||
hashlib.sha256(verifier.encode()).digest()
|
||||
).rstrip(b"=").decode()
|
||||
return verifier, challenge
|
||||
|
||||
|
||||
def run_hermes_oauth_login() -> Optional[str]:
|
||||
"""Run Hermes-native OAuth PKCE flow for Claude Pro/Max subscription.
|
||||
|
||||
Opens a browser to claude.ai for authorization, prompts for the code,
|
||||
exchanges it for tokens, and stores them in ~/.hermes/.anthropic_oauth.json.
|
||||
|
||||
Returns the access token on success, None on failure.
|
||||
"""
|
||||
import time
|
||||
import webbrowser
|
||||
|
||||
verifier, challenge = _generate_pkce()
|
||||
|
||||
# Build authorization URL
|
||||
params = {
|
||||
"code": "true",
|
||||
"client_id": _OAUTH_CLIENT_ID,
|
||||
"response_type": "code",
|
||||
"redirect_uri": _OAUTH_REDIRECT_URI,
|
||||
"scope": _OAUTH_SCOPES,
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"state": verifier,
|
||||
}
|
||||
from urllib.parse import urlencode
|
||||
auth_url = f"https://claude.ai/oauth/authorize?{urlencode(params)}"
|
||||
|
||||
print()
|
||||
print("Authorize Hermes with your Claude Pro/Max subscription.")
|
||||
print()
|
||||
print("╭─ Claude Pro/Max Authorization ────────────────────╮")
|
||||
print("│ │")
|
||||
print("│ Open this link in your browser: │")
|
||||
print("╰───────────────────────────────────────────────────╯")
|
||||
print()
|
||||
print(f" {auth_url}")
|
||||
print()
|
||||
|
||||
# Try to open browser automatically (works on desktop, silently fails on headless/SSH)
|
||||
try:
|
||||
webbrowser.open(auth_url)
|
||||
print(" (Browser opened automatically)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print()
|
||||
print("After authorizing, you'll see a code. Paste it below.")
|
||||
print()
|
||||
try:
|
||||
auth_code = input("Authorization code: ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
return None
|
||||
|
||||
if not auth_code:
|
||||
print("No code entered.")
|
||||
return None
|
||||
|
||||
# Split code#state format
|
||||
splits = auth_code.split("#")
|
||||
code = splits[0]
|
||||
state = splits[1] if len(splits) > 1 else ""
|
||||
|
||||
# Exchange code for tokens
|
||||
try:
|
||||
import urllib.request
|
||||
exchange_data = json.dumps({
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": _OAUTH_CLIENT_ID,
|
||||
"code": code,
|
||||
"state": state,
|
||||
"redirect_uri": _OAUTH_REDIRECT_URI,
|
||||
"code_verifier": verifier,
|
||||
}).encode()
|
||||
|
||||
req = urllib.request.Request(
|
||||
_OAUTH_TOKEN_URL,
|
||||
data=exchange_data,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": f"claude-cli/{_CLAUDE_CODE_VERSION} (external, cli)",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
result = json.loads(resp.read().decode())
|
||||
except Exception as e:
|
||||
print(f"Token exchange failed: {e}")
|
||||
return None
|
||||
|
||||
access_token = result.get("access_token", "")
|
||||
refresh_token = result.get("refresh_token", "")
|
||||
expires_in = result.get("expires_in", 3600)
|
||||
|
||||
if not access_token:
|
||||
print("No access token in response.")
|
||||
return None
|
||||
|
||||
# Store credentials
|
||||
expires_at_ms = int(time.time() * 1000) + (expires_in * 1000)
|
||||
_save_hermes_oauth_credentials(access_token, refresh_token, expires_at_ms)
|
||||
|
||||
# Also write to Claude Code's credential file for backward compat
|
||||
_write_claude_code_credentials(access_token, refresh_token, expires_at_ms)
|
||||
|
||||
print("Authentication successful!")
|
||||
return access_token
|
||||
|
||||
|
||||
def _save_hermes_oauth_credentials(access_token: str, refresh_token: str, expires_at_ms: int) -> None:
|
||||
"""Save OAuth credentials to ~/.hermes/.anthropic_oauth.json."""
|
||||
data = {
|
||||
"accessToken": access_token,
|
||||
"refreshToken": refresh_token,
|
||||
"expiresAt": expires_at_ms,
|
||||
}
|
||||
try:
|
||||
_HERMES_OAUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
_HERMES_OAUTH_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
_HERMES_OAUTH_FILE.chmod(0o600)
|
||||
except (OSError, IOError) as e:
|
||||
logger.debug("Failed to save Hermes OAuth credentials: %s", e)
|
||||
|
||||
|
||||
def read_hermes_oauth_credentials() -> Optional[Dict[str, Any]]:
|
||||
"""Read Hermes-managed OAuth credentials from ~/.hermes/.anthropic_oauth.json."""
|
||||
if _HERMES_OAUTH_FILE.exists():
|
||||
try:
|
||||
data = json.loads(_HERMES_OAUTH_FILE.read_text(encoding="utf-8"))
|
||||
if data.get("accessToken"):
|
||||
return data
|
||||
except (json.JSONDecodeError, OSError, IOError) as e:
|
||||
logger.debug("Failed to read Hermes OAuth credentials: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def refresh_hermes_oauth_token() -> Optional[str]:
|
||||
"""Refresh the Hermes-managed OAuth token using the stored refresh token.
|
||||
|
||||
Returns the new access token, or None if refresh fails.
|
||||
"""
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
creds = read_hermes_oauth_credentials()
|
||||
if not creds or not creds.get("refreshToken"):
|
||||
return None
|
||||
|
||||
try:
|
||||
data = json.dumps({
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": creds["refreshToken"],
|
||||
"client_id": _OAUTH_CLIENT_ID,
|
||||
}).encode()
|
||||
|
||||
req = urllib.request.Request(
|
||||
_OAUTH_TOKEN_URL,
|
||||
data=data,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": f"claude-cli/{_CLAUDE_CODE_VERSION} (external, cli)",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
result = json.loads(resp.read().decode())
|
||||
|
||||
new_access = result.get("access_token", "")
|
||||
new_refresh = result.get("refresh_token", creds["refreshToken"])
|
||||
expires_in = result.get("expires_in", 3600)
|
||||
|
||||
if new_access:
|
||||
new_expires_ms = int(time.time() * 1000) + (expires_in * 1000)
|
||||
_save_hermes_oauth_credentials(new_access, new_refresh, new_expires_ms)
|
||||
# Also update Claude Code's credential file
|
||||
_write_claude_code_credentials(new_access, new_refresh, new_expires_ms)
|
||||
logger.debug("Successfully refreshed Hermes OAuth token")
|
||||
return new_access
|
||||
except Exception as e:
|
||||
logger.debug("Failed to refresh Hermes OAuth token: %s", e)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+25
-11
@@ -41,7 +41,7 @@ import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from pathlib import Path # noqa: F401 — used by test mocks
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
@@ -1258,13 +1258,33 @@ def _get_cached_client(
|
||||
base_url: str = None,
|
||||
api_key: str = None,
|
||||
) -> Tuple[Optional[Any], Optional[str]]:
|
||||
"""Get or create a cached client for the given provider."""
|
||||
cache_key = (provider, async_mode, base_url or "", api_key or "")
|
||||
"""Get or create a cached client for the given provider.
|
||||
|
||||
Async clients (AsyncOpenAI) use httpx.AsyncClient internally, which
|
||||
binds to the event loop that was current when the client was created.
|
||||
Using such a client on a *different* loop causes deadlocks or
|
||||
RuntimeError. To prevent cross-loop issues (especially in gateway
|
||||
mode where _run_async() may spawn fresh loops in worker threads), the
|
||||
cache key for async clients includes the current event loop's identity
|
||||
so each loop gets its own client instance.
|
||||
"""
|
||||
# Include loop identity for async clients to prevent cross-loop reuse.
|
||||
# httpx.AsyncClient (inside AsyncOpenAI) is bound to the loop where it
|
||||
# was created — reusing it on a different loop causes deadlocks (#2681).
|
||||
loop_id = 0
|
||||
current_loop = None
|
||||
if async_mode:
|
||||
try:
|
||||
import asyncio as _aio
|
||||
current_loop = _aio.get_event_loop()
|
||||
loop_id = id(current_loop)
|
||||
except RuntimeError:
|
||||
pass
|
||||
cache_key = (provider, async_mode, base_url or "", api_key or "", loop_id)
|
||||
with _client_cache_lock:
|
||||
if cache_key in _client_cache:
|
||||
cached_client, cached_default, cached_loop = _client_cache[cache_key]
|
||||
if async_mode:
|
||||
# Async clients are bound to the event loop that created them.
|
||||
# A cached async client whose loop has been closed will raise
|
||||
# "Event loop is closed" when httpx tries to clean up its
|
||||
# transport. Discard the stale client and create a fresh one.
|
||||
@@ -1286,13 +1306,7 @@ def _get_cached_client(
|
||||
if client is not None:
|
||||
# For async clients, remember which loop they were created on so we
|
||||
# can detect stale entries later.
|
||||
bound_loop = None
|
||||
if async_mode:
|
||||
try:
|
||||
import asyncio as _aio
|
||||
bound_loop = _aio.get_event_loop()
|
||||
except RuntimeError:
|
||||
pass
|
||||
bound_loop = current_loop
|
||||
with _client_cache_lock:
|
||||
if cache_key not in _client_cache:
|
||||
_client_cache[cache_key] = (client, default_model, bound_loop)
|
||||
|
||||
@@ -14,7 +14,6 @@ Improvements over v1:
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.auxiliary_client import call_llm
|
||||
|
||||
+10
-2
@@ -252,6 +252,14 @@ class KawaiiSpinner:
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
|
||||
@property
|
||||
def _is_tty(self) -> bool:
|
||||
"""Check if output is a real terminal, safe against closed streams."""
|
||||
try:
|
||||
return hasattr(self._out, 'isatty') and self._out.isatty()
|
||||
except (ValueError, OSError):
|
||||
return False
|
||||
|
||||
def _is_patch_stdout_proxy(self) -> bool:
|
||||
"""Return True when stdout is prompt_toolkit's StdoutProxy.
|
||||
|
||||
@@ -272,7 +280,7 @@ class KawaiiSpinner:
|
||||
# When stdout is not a real terminal (e.g. Docker, systemd, pipe),
|
||||
# skip the animation entirely — it creates massive log bloat.
|
||||
# Just log the start once and let stop() log the completion.
|
||||
if not hasattr(self._out, 'isatty') or not self._out.isatty():
|
||||
if not self._is_tty:
|
||||
self._write(f" [tool] {self.message}", flush=True)
|
||||
while self.running:
|
||||
time.sleep(0.5)
|
||||
@@ -343,7 +351,7 @@ class KawaiiSpinner:
|
||||
if self.thread:
|
||||
self.thread.join(timeout=0.5)
|
||||
|
||||
is_tty = hasattr(self._out, 'isatty') and self._out.isatty()
|
||||
is_tty = self._is_tty
|
||||
if is_tty:
|
||||
# Clear the spinner line with spaces instead of \033[K to avoid
|
||||
# garbled escape codes when prompt_toolkit's patch_stdout is active.
|
||||
|
||||
+1
-1
@@ -666,7 +666,7 @@ class InsightsEngine:
|
||||
cost_cell = " N/A"
|
||||
lines.append(f" {model_name:<30} {m['sessions']:>8} {m['total_tokens']:>12,} {cost_cell}")
|
||||
if o.get("models_without_pricing"):
|
||||
lines.append(f" * Cost N/A for custom/self-hosted models")
|
||||
lines.append(" * Cost N/A for custom/self-hosted models")
|
||||
lines.append("")
|
||||
|
||||
# Platform breakdown
|
||||
|
||||
+16
-34
@@ -8,6 +8,8 @@ import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -320,7 +322,7 @@ def build_skills_system_prompt(
|
||||
match skills by meaning, not just name.
|
||||
Filters out skills incompatible with the current OS platform.
|
||||
"""
|
||||
hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||
hermes_home = get_hermes_home()
|
||||
skills_dir = hermes_home / "skills"
|
||||
|
||||
if not skills_dir.exists():
|
||||
@@ -356,7 +358,7 @@ def build_skills_system_prompt(
|
||||
continue
|
||||
# Extract conditions inline from already-parsed frontmatter
|
||||
# (avoids redundant file re-read that _read_skill_conditions would do)
|
||||
hermes_meta = frontmatter.get("metadata", {}).get("hermes", {})
|
||||
hermes_meta = (frontmatter.get("metadata") or {}).get("hermes") or {}
|
||||
conditions = {
|
||||
"fallback_for_toolsets": hermes_meta.get("fallback_for_toolsets", []),
|
||||
"requires_toolsets": hermes_meta.get("requires_toolsets", []),
|
||||
@@ -449,7 +451,7 @@ def load_soul_md() -> Optional[str]:
|
||||
except Exception as e:
|
||||
logger.debug("Could not ensure HERMES_HOME before loading SOUL.md: %s", e)
|
||||
|
||||
soul_path = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) / "SOUL.md"
|
||||
soul_path = get_hermes_home() / "SOUL.md"
|
||||
if not soul_path.exists():
|
||||
return None
|
||||
try:
|
||||
@@ -488,39 +490,19 @@ def _load_hermes_md(cwd_path: Path) -> str:
|
||||
|
||||
|
||||
def _load_agents_md(cwd_path: Path) -> str:
|
||||
"""AGENTS.md — hierarchical, recursive directory walk."""
|
||||
top_level_agents = None
|
||||
"""AGENTS.md — top-level only (no recursive walk)."""
|
||||
for name in ["AGENTS.md", "agents.md"]:
|
||||
candidate = cwd_path / name
|
||||
if candidate.exists():
|
||||
top_level_agents = candidate
|
||||
break
|
||||
|
||||
if not top_level_agents:
|
||||
return ""
|
||||
|
||||
agents_files = []
|
||||
for root, dirs, files in os.walk(cwd_path):
|
||||
dirs[:] = [d for d in dirs if not d.startswith('.') and d not in ('node_modules', '__pycache__', 'venv', '.venv')]
|
||||
for f in files:
|
||||
if f.lower() == "agents.md":
|
||||
agents_files.append(Path(root) / f)
|
||||
agents_files.sort(key=lambda p: len(p.parts))
|
||||
|
||||
total_content = ""
|
||||
for agents_path in agents_files:
|
||||
try:
|
||||
content = agents_path.read_text(encoding="utf-8").strip()
|
||||
if content:
|
||||
rel_path = agents_path.relative_to(cwd_path)
|
||||
content = _scan_context_content(content, str(rel_path))
|
||||
total_content += f"## {rel_path}\n\n{content}\n\n"
|
||||
except Exception as e:
|
||||
logger.debug("Could not read %s: %s", agents_path, e)
|
||||
|
||||
if not total_content:
|
||||
return ""
|
||||
return _truncate_content(total_content, "AGENTS.md")
|
||||
try:
|
||||
content = candidate.read_text(encoding="utf-8").strip()
|
||||
if content:
|
||||
content = _scan_context_content(content, name)
|
||||
result = f"## {name}\n\n{content}"
|
||||
return _truncate_content(result, "AGENTS.md")
|
||||
except Exception as e:
|
||||
logger.debug("Could not read %s: %s", candidate, e)
|
||||
return ""
|
||||
|
||||
|
||||
def _load_claude_md(cwd_path: Path) -> str:
|
||||
@@ -574,7 +556,7 @@ def build_context_files_prompt(cwd: Optional[str] = None, skip_soul: bool = Fals
|
||||
|
||||
Priority (first found wins — only ONE project context type is loaded):
|
||||
1. .hermes.md / HERMES.md (walk to git root)
|
||||
2. AGENTS.md / agents.md (recursive directory walk)
|
||||
2. AGENTS.md / agents.md (cwd only)
|
||||
3. CLAUDE.md / claude.md (cwd only)
|
||||
4. .cursorrules / .cursor/rules/*.mdc (cwd only)
|
||||
|
||||
|
||||
@@ -649,7 +649,8 @@ def format_token_count_compact(value: int) -> str:
|
||||
text = f"{scaled:.1f}"
|
||||
else:
|
||||
text = f"{scaled:.0f}"
|
||||
text = text.rstrip("0").rstrip(".")
|
||||
if "." in text:
|
||||
text = text.rstrip("0").rstrip(".")
|
||||
return f"{sign}{text}{suffix}"
|
||||
|
||||
return f"{value:,}"
|
||||
|
||||
@@ -70,10 +70,10 @@ _COMMAND_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧
|
||||
|
||||
# Load .env from ~/.hermes/.env first, then project root as dev fallback.
|
||||
# User-managed env files should override stale shell exports on restart.
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
from hermes_constants import get_hermes_home, OPENROUTER_BASE_URL
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
|
||||
_hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||
_hermes_home = get_hermes_home()
|
||||
_project_env = Path(__file__).parent / '.env'
|
||||
load_hermes_dotenv(hermes_home=_hermes_home, project_env=_project_env)
|
||||
|
||||
@@ -112,21 +112,12 @@ def _load_prefill_messages(file_path: str) -> List[Dict[str, Any]]:
|
||||
|
||||
|
||||
def _parse_reasoning_config(effort: str) -> dict | None:
|
||||
"""Parse a reasoning effort level into an OpenRouter reasoning config dict.
|
||||
|
||||
Valid levels: "xhigh", "high", "medium", "low", "minimal", "none".
|
||||
Returns None to use the default (medium), or a config dict to override.
|
||||
"""
|
||||
if not effort or not effort.strip():
|
||||
return None
|
||||
effort = effort.strip().lower()
|
||||
if effort == "none":
|
||||
return {"enabled": False}
|
||||
valid = ("xhigh", "high", "medium", "low", "minimal")
|
||||
if effort in valid:
|
||||
return {"enabled": True, "effort": effort}
|
||||
logger.warning("Unknown reasoning_effort '%s', using default (medium)", effort)
|
||||
return None
|
||||
"""Parse a reasoning effort level into an OpenRouter reasoning config dict."""
|
||||
from hermes_constants import parse_reasoning_effort
|
||||
result = parse_reasoning_effort(effort)
|
||||
if effort and effort.strip() and result is None:
|
||||
logger.warning("Unknown reasoning_effort '%s', using default (medium)", effort)
|
||||
return result
|
||||
|
||||
|
||||
def load_cli_config() -> Dict[str, Any]:
|
||||
@@ -270,6 +261,18 @@ def load_cli_config() -> Dict[str, Any]:
|
||||
elif isinstance(file_config["model"], dict):
|
||||
# Old format: model is a dict with default/base_url
|
||||
defaults["model"].update(file_config["model"])
|
||||
|
||||
# Root-level provider and base_url override model config.
|
||||
# Users may write:
|
||||
# model: kimi-k2.5:cloud
|
||||
# provider: custom
|
||||
# base_url: http://localhost:11434/v1
|
||||
# These root-level keys must be merged into defaults["model"] so
|
||||
# they are picked up by CLI provider resolution.
|
||||
if "provider" in file_config and file_config["provider"]:
|
||||
defaults["model"]["provider"] = file_config["provider"]
|
||||
if "base_url" in file_config and file_config["base_url"]:
|
||||
defaults["model"]["base_url"] = file_config["base_url"]
|
||||
|
||||
# Deep merge file_config into defaults.
|
||||
# First: merge keys that exist in both (deep-merge dicts, overwrite scalars)
|
||||
@@ -458,13 +461,8 @@ from run_agent import AIAgent
|
||||
from model_tools import get_tool_definitions, get_toolset_for_tool
|
||||
|
||||
# Extracted CLI modules (Phase 3)
|
||||
from hermes_cli.banner import (
|
||||
cprint as _cprint, _GOLD, _BOLD, _DIM, _RST,
|
||||
HERMES_AGENT_LOGO, HERMES_CADUCEUS, COMPACT_BANNER,
|
||||
build_welcome_banner,
|
||||
)
|
||||
from hermes_cli.commands import COMMANDS, SlashCommandCompleter, SlashCommandAutoSuggest
|
||||
from hermes_cli import callbacks as _callbacks
|
||||
from hermes_cli.banner import build_welcome_banner
|
||||
from hermes_cli.commands import SlashCommandCompleter, SlashCommandAutoSuggest
|
||||
from toolsets import get_all_toolsets, get_toolset_info, validate_toolset
|
||||
|
||||
# Cron job system for scheduled tasks (execution is handled by the gateway)
|
||||
@@ -1054,6 +1052,8 @@ class HermesCLI:
|
||||
self._stream_buf = "" # Partial line buffer for line-buffered rendering
|
||||
self._stream_started = False # True once first delta arrives
|
||||
self._stream_box_opened = False # True once the response box header is printed
|
||||
self._reasoning_stream_started = False # True once live reasoning starts streaming
|
||||
self._reasoning_preview_buf = "" # Coalesce tiny reasoning chunks for [thinking] output
|
||||
|
||||
# Configuration - priority: CLI args > env vars > config file
|
||||
# Model comes from: CLI arg or config.yaml (single source of truth).
|
||||
@@ -1105,7 +1105,7 @@ class HermesCLI:
|
||||
# Match key to resolved base_url: OpenRouter URL → prefer OPENROUTER_API_KEY,
|
||||
# custom endpoint → prefer OPENAI_API_KEY (issue #560).
|
||||
# Note: _ensure_runtime_credentials() re-resolves this before first use.
|
||||
if "openrouter.ai" in self.base_url:
|
||||
if self.base_url and "openrouter.ai" in self.base_url:
|
||||
self.api_key = api_key or os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENAI_API_KEY")
|
||||
else:
|
||||
self.api_key = api_key or os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY")
|
||||
@@ -1186,8 +1186,8 @@ class HermesCLI:
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
self._session_db = SessionDB()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("Failed to initialize SessionDB — session will NOT be indexed for search: %s", e)
|
||||
|
||||
# Deferred title: stored in memory until the session is created in the DB
|
||||
self._pending_title: Optional[str] = None
|
||||
@@ -1478,11 +1478,108 @@ class HermesCLI:
|
||||
|
||||
def _on_thinking(self, text: str) -> None:
|
||||
"""Called by agent when thinking starts/stops. Updates TUI spinner."""
|
||||
if not text:
|
||||
self._flush_reasoning_preview(force=True)
|
||||
self._spinner_text = text or ""
|
||||
self._invalidate()
|
||||
|
||||
# ── Streaming display ────────────────────────────────────────────────
|
||||
|
||||
def _current_reasoning_callback(self):
|
||||
"""Return the active reasoning display callback for the current mode."""
|
||||
if self.show_reasoning and self.streaming_enabled:
|
||||
return self._stream_reasoning_delta
|
||||
if self.verbose and not self.show_reasoning:
|
||||
return self._on_reasoning
|
||||
return None
|
||||
|
||||
def _emit_reasoning_preview(self, reasoning_text: str) -> None:
|
||||
"""Render a buffered reasoning preview as a single [thinking] block."""
|
||||
import re
|
||||
import textwrap
|
||||
|
||||
preview_text = reasoning_text.strip()
|
||||
if not preview_text:
|
||||
return
|
||||
|
||||
try:
|
||||
term_width = shutil.get_terminal_size().columns
|
||||
except Exception:
|
||||
term_width = 80
|
||||
prefix = " [thinking] "
|
||||
wrap_width = max(30, term_width - len(prefix) - 2)
|
||||
|
||||
paragraphs = []
|
||||
raw_paragraphs = re.split(r"\n\s*\n+", preview_text.replace("\r\n", "\n"))
|
||||
for paragraph in raw_paragraphs:
|
||||
compact = " ".join(line.strip() for line in paragraph.splitlines() if line.strip())
|
||||
if compact:
|
||||
paragraphs.append(textwrap.fill(compact, width=wrap_width))
|
||||
preview_text = "\n".join(paragraphs)
|
||||
if not preview_text:
|
||||
return
|
||||
|
||||
if self.verbose:
|
||||
_cprint(f" {_DIM}[thinking] {preview_text}{_RST}")
|
||||
return
|
||||
|
||||
lines = preview_text.splitlines()
|
||||
if len(lines) > 5:
|
||||
preview = "\n".join(lines[:5])
|
||||
preview += f"\n ... ({len(lines) - 5} more lines)"
|
||||
else:
|
||||
preview = preview_text
|
||||
_cprint(f" {_DIM}[thinking] {preview}{_RST}")
|
||||
|
||||
def _flush_reasoning_preview(self, *, force: bool = False) -> None:
|
||||
"""Flush buffered reasoning text at natural boundaries.
|
||||
|
||||
Some providers stream reasoning in tiny word or punctuation chunks.
|
||||
Buffer them here so the preview path does not print one `[thinking]`
|
||||
line per token.
|
||||
"""
|
||||
buf = getattr(self, "_reasoning_preview_buf", "")
|
||||
if not buf:
|
||||
return
|
||||
|
||||
try:
|
||||
term_width = shutil.get_terminal_size().columns
|
||||
except Exception:
|
||||
term_width = 80
|
||||
target_width = max(40, term_width - len(" [thinking] ") - 4)
|
||||
|
||||
flush_text = ""
|
||||
|
||||
if force:
|
||||
flush_text = buf
|
||||
buf = ""
|
||||
else:
|
||||
line_break = buf.rfind("\n")
|
||||
min_newline_flush = max(16, target_width // 3)
|
||||
if line_break != -1 and (
|
||||
line_break >= min_newline_flush
|
||||
or buf.endswith("\n\n")
|
||||
or buf.endswith(".\n")
|
||||
or buf.endswith("!\n")
|
||||
or buf.endswith("?\n")
|
||||
or buf.endswith(":\n")
|
||||
):
|
||||
flush_text = buf[: line_break + 1]
|
||||
buf = buf[line_break + 1 :]
|
||||
elif len(buf) >= target_width:
|
||||
search_start = max(20, target_width // 2)
|
||||
search_end = min(len(buf), max(target_width + (target_width // 3), target_width + 8))
|
||||
cut = -1
|
||||
for boundary in (" ", "\t", ".", "!", "?", ",", ";", ":"):
|
||||
cut = max(cut, buf.rfind(boundary, search_start, search_end))
|
||||
if cut != -1:
|
||||
flush_text = buf[: cut + 1]
|
||||
buf = buf[cut + 1 :]
|
||||
|
||||
self._reasoning_preview_buf = buf.lstrip() if flush_text else buf
|
||||
if flush_text:
|
||||
self._emit_reasoning_preview(flush_text)
|
||||
|
||||
def _stream_reasoning_delta(self, text: str) -> None:
|
||||
"""Stream reasoning/thinking tokens into a dim box above the response.
|
||||
|
||||
@@ -1496,6 +1593,7 @@ class HermesCLI:
|
||||
"""
|
||||
if not text:
|
||||
return
|
||||
self._reasoning_stream_started = True
|
||||
if getattr(self, "_stream_box_opened", False):
|
||||
return
|
||||
|
||||
@@ -1691,11 +1789,13 @@ class HermesCLI:
|
||||
self._stream_buf = ""
|
||||
self._stream_started = False
|
||||
self._stream_box_opened = False
|
||||
self._reasoning_stream_started = False
|
||||
self._stream_text_ansi = ""
|
||||
self._stream_prefilt = ""
|
||||
self._in_reasoning_block = False
|
||||
self._reasoning_box_opened = False
|
||||
self._reasoning_buf = ""
|
||||
self._reasoning_preview_buf = ""
|
||||
|
||||
def _slow_command_status(self, command: str) -> str:
|
||||
"""Return a user-facing status message for slower slash commands."""
|
||||
@@ -1852,7 +1952,7 @@ class HermesCLI:
|
||||
from hermes_state import SessionDB
|
||||
self._session_db = SessionDB()
|
||||
except Exception as e:
|
||||
logger.debug("SQLite session store not available: %s", e)
|
||||
logger.warning("SQLite session store not available — session will NOT be indexed: %s", e)
|
||||
|
||||
# If resuming, validate the session exists and load its history.
|
||||
# _preload_resumed_session() may have already loaded it (called from
|
||||
@@ -1926,11 +2026,7 @@ class HermesCLI:
|
||||
platform="cli",
|
||||
session_db=self._session_db,
|
||||
clarify_callback=self._clarify_callback,
|
||||
reasoning_callback=(
|
||||
self._stream_reasoning_delta if (self.streaming_enabled and self.show_reasoning)
|
||||
else self._on_reasoning if (self.show_reasoning or self.verbose)
|
||||
else None
|
||||
),
|
||||
reasoning_callback=self._current_reasoning_callback(),
|
||||
honcho_session_key=None, # resolved by run_agent via config sessions map / title
|
||||
fallback_model=self._fallback_model,
|
||||
thinking_callback=self._on_thinking,
|
||||
@@ -2223,7 +2319,7 @@ class HermesCLI:
|
||||
"""
|
||||
from hermes_cli.clipboard import save_clipboard_image
|
||||
|
||||
img_dir = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) / "images"
|
||||
img_dir = get_hermes_home() / "images"
|
||||
self._image_counter += 1
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
img_path = img_dir / f"clip_{ts}_{self._image_counter}.png"
|
||||
@@ -2243,7 +2339,7 @@ class HermesCLI:
|
||||
/rollback diff <N> — preview changes since checkpoint N
|
||||
/rollback <N> <file> — restore a single file from checkpoint N
|
||||
"""
|
||||
from tools.checkpoint_manager import CheckpointManager, format_checkpoint_list
|
||||
from tools.checkpoint_manager import format_checkpoint_list
|
||||
|
||||
if not hasattr(self, 'agent') or not self.agent:
|
||||
print(" No active agent session.")
|
||||
@@ -2318,13 +2414,13 @@ class HermesCLI:
|
||||
print(f" ✅ Restored {file_path} from checkpoint {result['restored_to']}: {result['reason']}")
|
||||
else:
|
||||
print(f" ✅ Restored to checkpoint {result['restored_to']}: {result['reason']}")
|
||||
print(f" A pre-rollback snapshot was saved automatically.")
|
||||
print(" A pre-rollback snapshot was saved automatically.")
|
||||
|
||||
# Also undo the last conversation turn so the agent's context
|
||||
# matches the restored filesystem state
|
||||
if self.conversation_history:
|
||||
self.undo_last()
|
||||
print(f" Chat turn undone to match restored file state.")
|
||||
print(" Chat turn undone to match restored file state.")
|
||||
else:
|
||||
print(f" ❌ {result['error']}")
|
||||
|
||||
@@ -2443,7 +2539,7 @@ class HermesCLI:
|
||||
def _show_tool_availability_warnings(self):
|
||||
"""Show warnings about disabled tools due to missing API keys."""
|
||||
try:
|
||||
from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS
|
||||
from model_tools import check_tool_availability
|
||||
|
||||
available, unavailable = check_tool_availability()
|
||||
|
||||
@@ -2783,7 +2879,7 @@ class HermesCLI:
|
||||
if self.agent and self.conversation_history:
|
||||
try:
|
||||
self.agent.flush_memories(self.conversation_history)
|
||||
except Exception:
|
||||
except (Exception, KeyboardInterrupt):
|
||||
pass
|
||||
|
||||
old_session_id = self.session_id
|
||||
@@ -2919,10 +3015,10 @@ class HermesCLI:
|
||||
print(f" {remaining} message(s) remaining in history.")
|
||||
|
||||
def _show_model_and_providers(self):
|
||||
"""Unified /model and /provider display.
|
||||
"""Show current model + provider and list all authenticated providers.
|
||||
|
||||
Shows current model + provider, then lists all authenticated
|
||||
providers with their available models so users can switch easily.
|
||||
providers with their available models.
|
||||
"""
|
||||
from hermes_cli.models import (
|
||||
curated_models_for_provider, list_available_providers,
|
||||
@@ -2971,26 +3067,18 @@ class HermesCLI:
|
||||
print(f" endpoint: {custom_url}")
|
||||
if is_active:
|
||||
print(f" model: {self.model} ← current")
|
||||
print(f" (use /model custom:<model-name>)")
|
||||
print(" (use hermes model to change)")
|
||||
else:
|
||||
print(f" (use /model {p['id']}:<model-name>)")
|
||||
print(" (use hermes model to change)")
|
||||
print()
|
||||
|
||||
if unauthed:
|
||||
names = ", ".join(p["label"] for p in unauthed)
|
||||
print(f" Not configured: {names}")
|
||||
print(f" Run: hermes setup")
|
||||
print(" Run: hermes setup")
|
||||
print()
|
||||
|
||||
print(" Switch model: /model <model-name>")
|
||||
print(" Switch provider: /model <provider>:<model-name>")
|
||||
if authed and len(authed) > 1:
|
||||
# Show a concrete example with a non-active provider
|
||||
other = next((p for p in authed if p["id"] != current), authed[0])
|
||||
other_models = curated_models_for_provider(other["id"])
|
||||
if other_models:
|
||||
example_model = other_models[0][0]
|
||||
print(f" Example: /model {other['id']}:{example_model}")
|
||||
print(" To change model or provider, use: hermes model")
|
||||
|
||||
def _handle_prompt_command(self, cmd: str):
|
||||
"""Handle the /prompt command to view or set system prompt."""
|
||||
@@ -3011,9 +3099,9 @@ class HermesCLI:
|
||||
self.system_prompt = new_prompt
|
||||
self.agent = None # Force re-init
|
||||
if save_config_value("agent.system_prompt", new_prompt):
|
||||
print(f"(^_^)b System prompt set (saved to config)")
|
||||
print("(^_^)b System prompt set (saved to config)")
|
||||
else:
|
||||
print(f"(^_^) System prompt set (session only)")
|
||||
print("(^_^) System prompt set (session only)")
|
||||
print(f" \"{new_prompt[:60]}{'...' if len(new_prompt) > 60 else ''}\"")
|
||||
else:
|
||||
# Show current prompt
|
||||
@@ -3554,96 +3642,11 @@ class HermesCLI:
|
||||
elif self._pending_title:
|
||||
_cprint(f" Title (pending): {self._pending_title}")
|
||||
else:
|
||||
_cprint(f" No title set. Usage: /title <your session title>")
|
||||
_cprint(" No title set. Usage: /title <your session title>")
|
||||
else:
|
||||
_cprint(" Session database not available.")
|
||||
elif canonical == "new":
|
||||
self.new_session()
|
||||
elif canonical == "model":
|
||||
# Use original case so model names like "Anthropic/Claude-Opus-4" are preserved
|
||||
parts = cmd_original.split(maxsplit=1)
|
||||
if len(parts) > 1:
|
||||
from hermes_cli.model_switch import switch_model, switch_to_custom_provider
|
||||
|
||||
raw_input = parts[1].strip()
|
||||
|
||||
# Handle bare "/model custom" — switch to custom provider
|
||||
# and auto-detect the model from the endpoint.
|
||||
if raw_input.strip().lower() == "custom":
|
||||
result = switch_to_custom_provider()
|
||||
if result.success:
|
||||
self.model = result.model
|
||||
self.requested_provider = "custom"
|
||||
self.provider = "custom"
|
||||
self.api_key = result.api_key
|
||||
self.base_url = result.base_url
|
||||
self.agent = None
|
||||
save_config_value("model.default", result.model)
|
||||
save_config_value("model.provider", "custom")
|
||||
save_config_value("model.base_url", result.base_url)
|
||||
print(f"(^_^)b Model changed to: {result.model} [provider: Custom]")
|
||||
print(f" Endpoint: {result.base_url}")
|
||||
print(f" Status: connected (model auto-detected)")
|
||||
else:
|
||||
print(f"(>_<) {result.error_message}")
|
||||
return True
|
||||
|
||||
# Core model-switching pipeline (shared with gateway)
|
||||
current_provider = self.provider or self.requested_provider or "openrouter"
|
||||
result = switch_model(
|
||||
raw_input,
|
||||
current_provider,
|
||||
current_base_url=self.base_url or "",
|
||||
current_api_key=self.api_key or "",
|
||||
)
|
||||
|
||||
if not result.success:
|
||||
print(f"(>_<) {result.error_message}")
|
||||
if "Did you mean" not in result.error_message:
|
||||
print(f" Model unchanged: {self.model}")
|
||||
if "credentials" not in result.error_message.lower():
|
||||
print(" Tip: Use /model to see available models, /provider to see providers")
|
||||
else:
|
||||
self.model = result.new_model
|
||||
self.agent = None # Force re-init
|
||||
|
||||
if result.provider_changed:
|
||||
self.requested_provider = result.target_provider
|
||||
self.provider = result.target_provider
|
||||
self.api_key = result.api_key
|
||||
self.base_url = result.base_url
|
||||
|
||||
provider_note = f" [provider: {result.provider_label}]" if result.provider_changed else ""
|
||||
|
||||
if result.persist:
|
||||
saved_model = save_config_value("model.default", result.new_model)
|
||||
if result.provider_changed:
|
||||
save_config_value("model.provider", result.target_provider)
|
||||
# Persist base_url for custom endpoints; clear
|
||||
# when switching away from custom (#2562 Phase 2).
|
||||
if result.base_url and "openrouter.ai" not in (result.base_url or ""):
|
||||
save_config_value("model.base_url", result.base_url)
|
||||
else:
|
||||
save_config_value("model.base_url", None)
|
||||
if saved_model:
|
||||
print(f"(^_^)b Model changed to: {result.new_model}{provider_note} (saved to config)")
|
||||
else:
|
||||
print(f"(^_^) Model changed to: {result.new_model}{provider_note} (this session only)")
|
||||
else:
|
||||
print(f"(^_^) Model changed to: {result.new_model}{provider_note} (this session only)")
|
||||
if result.warning_message:
|
||||
print(f" Reason: {result.warning_message}")
|
||||
print(" Note: Model will revert on restart. Use a verified model to save to config.")
|
||||
|
||||
# Show endpoint info for custom providers
|
||||
if result.is_custom_target:
|
||||
endpoint = result.base_url or self.base_url or "custom endpoint"
|
||||
print(f" Endpoint: {endpoint}")
|
||||
if not result.provider_changed:
|
||||
print(f" Tip: To switch providers, use /model provider:model")
|
||||
print(f" e.g. /model openai-codex:gpt-5.2-codex")
|
||||
else:
|
||||
self._show_model_and_providers()
|
||||
elif canonical == "provider":
|
||||
self._show_model_and_providers()
|
||||
elif canonical == "prompt":
|
||||
@@ -3698,7 +3701,7 @@ class HermesCLI:
|
||||
plugins = mgr.list_plugins()
|
||||
if not plugins:
|
||||
print("No plugins installed.")
|
||||
print(f"Drop plugin directories into ~/.hermes/plugins/ to get started.")
|
||||
print("Drop plugin directories into ~/.hermes/plugins/ to get started.")
|
||||
else:
|
||||
print(f"Plugins ({len(plugins)}):")
|
||||
for p in plugins:
|
||||
@@ -3891,7 +3894,7 @@ class HermesCLI:
|
||||
|
||||
_cprint(f" 🔄 Background task #{task_num} started: \"{prompt[:60]}{'...' if len(prompt) > 60 else ''}\"")
|
||||
_cprint(f" Task ID: {task_id}")
|
||||
_cprint(f" You can continue chatting — results will appear when done.\n")
|
||||
_cprint(" You can continue chatting — results will appear when done.\n")
|
||||
|
||||
turn_route = self._resolve_turn_agent_config(prompt)
|
||||
|
||||
@@ -3931,7 +3934,13 @@ class HermesCLI:
|
||||
if not response and result and result.get("error"):
|
||||
response = f"Error: {result['error']}"
|
||||
|
||||
# Display result in the CLI (thread-safe via patch_stdout)
|
||||
# Display result in the CLI (thread-safe via patch_stdout).
|
||||
# Force a TUI refresh first so spinner/status bar don't overlap
|
||||
# with the output (fixes #2718).
|
||||
if self._app:
|
||||
self._app.invalidate()
|
||||
import time as _tmod
|
||||
_tmod.sleep(0.05) # brief pause for refresh
|
||||
print()
|
||||
ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]")
|
||||
_cprint(f" ✅ Background task #{task_num} complete")
|
||||
@@ -3968,6 +3977,11 @@ class HermesCLI:
|
||||
sys.stdout.flush()
|
||||
|
||||
except Exception as e:
|
||||
# Same TUI refresh pattern as success path (#2718)
|
||||
if self._app:
|
||||
self._app.invalidate()
|
||||
import time as _tmod
|
||||
_tmod.sleep(0.05)
|
||||
print()
|
||||
_cprint(f" ❌ Background task #{task_num} failed: {e}")
|
||||
finally:
|
||||
@@ -4025,7 +4039,6 @@ class HermesCLI:
|
||||
def _handle_browser_command(self, cmd: str):
|
||||
"""Handle /browser connect|disconnect|status — manage live Chrome CDP connection."""
|
||||
import platform as _plat
|
||||
import subprocess as _sp
|
||||
|
||||
parts = cmd.strip().split(None, 1)
|
||||
sub = parts[1].lower().strip() if len(parts) > 1 else "status"
|
||||
@@ -4091,7 +4104,7 @@ class HermesCLI:
|
||||
print(f" ⚠ Chrome launched but port {_port} isn't responding yet")
|
||||
print(" You may need to close existing Chrome windows first and retry")
|
||||
else:
|
||||
print(f" ⚠ Could not auto-launch Chrome")
|
||||
print(" ⚠ Could not auto-launch Chrome")
|
||||
# Show manual instructions as fallback
|
||||
sys_name = _plat.system()
|
||||
if sys_name == "Darwin":
|
||||
@@ -4148,7 +4161,7 @@ class HermesCLI:
|
||||
elif sub == "status":
|
||||
print()
|
||||
if current:
|
||||
print(f"🌐 Browser: connected to live Chrome via CDP")
|
||||
print("🌐 Browser: connected to live Chrome via CDP")
|
||||
print(f" Endpoint: {current}")
|
||||
|
||||
_port = 9222
|
||||
@@ -4162,9 +4175,9 @@ class HermesCLI:
|
||||
s.settimeout(1)
|
||||
s.connect(("127.0.0.1", _port))
|
||||
s.close()
|
||||
print(f" Status: ✓ reachable")
|
||||
print(" Status: ✓ reachable")
|
||||
except (OSError, Exception):
|
||||
print(f" Status: ⚠ not reachable (Chrome may not be running)")
|
||||
print(" Status: ⚠ not reachable (Chrome may not be running)")
|
||||
elif os.environ.get("BROWSERBASE_API_KEY"):
|
||||
print("🌐 Browser: Browserbase (cloud)")
|
||||
else:
|
||||
@@ -4197,13 +4210,13 @@ class HermesCLI:
|
||||
current = get_active_skin_name()
|
||||
skins = list_skins()
|
||||
print(f"\n Current skin: {current}")
|
||||
print(f" Available skins:")
|
||||
print(" Available skins:")
|
||||
for s in skins:
|
||||
marker = " ●" if s["name"] == current else " "
|
||||
source = f" ({s['source']})" if s["source"] == "user" else ""
|
||||
print(f" {marker} {s['name']}{source} — {s['description']}")
|
||||
print(f"\n Usage: /skin <name>")
|
||||
print(f" Custom skins: drop a YAML file in ~/.hermes/skins/\n")
|
||||
print("\n Usage: /skin <name>")
|
||||
print(" Custom skins: drop a YAML file in ~/.hermes/skins/\n")
|
||||
return
|
||||
|
||||
new_skin = parts[1].strip().lower()
|
||||
@@ -4235,11 +4248,7 @@ class HermesCLI:
|
||||
if self.agent:
|
||||
self.agent.verbose_logging = self.verbose
|
||||
self.agent.quiet_mode = not self.verbose
|
||||
# Auto-enable reasoning display in verbose mode
|
||||
if self.verbose:
|
||||
self.agent.reasoning_callback = self._on_reasoning
|
||||
elif not self.show_reasoning:
|
||||
self.agent.reasoning_callback = None
|
||||
self.agent.reasoning_callback = self._current_reasoning_callback()
|
||||
|
||||
# Use raw ANSI codes via _cprint so the output is routed through
|
||||
# prompt_toolkit's renderer. self.console.print() with Rich markup
|
||||
@@ -4286,7 +4295,7 @@ class HermesCLI:
|
||||
if arg in ("show", "on"):
|
||||
self.show_reasoning = True
|
||||
if self.agent:
|
||||
self.agent.reasoning_callback = self._on_reasoning
|
||||
self.agent.reasoning_callback = self._current_reasoning_callback()
|
||||
save_config_value("display.show_reasoning", True)
|
||||
_cprint(f" {_GOLD}✓ Reasoning display: ON (saved){_RST}")
|
||||
_cprint(f" {_DIM} Model thinking will be shown during and after each response.{_RST}")
|
||||
@@ -4294,7 +4303,7 @@ class HermesCLI:
|
||||
if arg in ("hide", "off"):
|
||||
self.show_reasoning = False
|
||||
if self.agent:
|
||||
self.agent.reasoning_callback = None
|
||||
self.agent.reasoning_callback = self._current_reasoning_callback()
|
||||
save_config_value("display.show_reasoning", False)
|
||||
_cprint(f" {_GOLD}✓ Reasoning display: OFF (saved){_RST}")
|
||||
return
|
||||
@@ -4317,17 +4326,10 @@ class HermesCLI:
|
||||
|
||||
def _on_reasoning(self, reasoning_text: str):
|
||||
"""Callback for intermediate reasoning display during tool-call loops."""
|
||||
if self.verbose:
|
||||
# Verbose mode: show full reasoning text
|
||||
_cprint(f" {_DIM}[thinking] {reasoning_text.strip()}{_RST}")
|
||||
else:
|
||||
lines = reasoning_text.strip().splitlines()
|
||||
if len(lines) > 5:
|
||||
preview = "\n".join(lines[:5])
|
||||
preview += f"\n ... ({len(lines) - 5} more lines)"
|
||||
else:
|
||||
preview = reasoning_text.strip()
|
||||
_cprint(f" {_DIM}[thinking] {preview}{_RST}")
|
||||
if not reasoning_text:
|
||||
return
|
||||
self._reasoning_preview_buf = getattr(self, "_reasoning_preview_buf", "") + reasoning_text
|
||||
self._flush_reasoning_preview(force=False)
|
||||
|
||||
def _manual_compress(self):
|
||||
"""Manually trigger context compression on the current conversation."""
|
||||
@@ -4411,7 +4413,7 @@ class HermesCLI:
|
||||
)
|
||||
elapsed = format_duration_compact((datetime.now() - self.session_start).total_seconds())
|
||||
|
||||
print(f" 📊 Session Token Usage")
|
||||
print(" 📊 Session Token Usage")
|
||||
print(f" {'─' * 40}")
|
||||
print(f" Model: {agent.model}")
|
||||
print(f" Input tokens: {input_tokens:>10,}")
|
||||
@@ -4538,7 +4540,7 @@ class HermesCLI:
|
||||
sees the updated tools on the next turn.
|
||||
"""
|
||||
try:
|
||||
from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools, _load_mcp_config, _servers, _lock
|
||||
from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools, _servers, _lock
|
||||
|
||||
# Capture old server names
|
||||
with _lock:
|
||||
@@ -4858,7 +4860,6 @@ class HermesCLI:
|
||||
try:
|
||||
from tools.tts_tool import text_to_speech_tool
|
||||
from tools.voice_mode import play_audio_file
|
||||
import json
|
||||
import re
|
||||
|
||||
# Strip markdown and non-speech content for cleaner TTS
|
||||
@@ -5536,7 +5537,7 @@ class HermesCLI:
|
||||
# But if it does (race condition), don't interrupt.
|
||||
if self._clarify_state or self._clarify_freetext:
|
||||
continue
|
||||
print(f"\n⚡ New message detected, interrupting...")
|
||||
print("\n⚡ New message detected, interrupting...")
|
||||
# Signal TTS to stop on interrupt
|
||||
if stop_event is not None:
|
||||
stop_event.set()
|
||||
@@ -5628,7 +5629,7 @@ class HermesCLI:
|
||||
|
||||
# Display reasoning (thinking) box if enabled and available.
|
||||
# Skip when streaming already showed reasoning live.
|
||||
if self.show_reasoning and result and not self._stream_started:
|
||||
if self.show_reasoning and result and not self._reasoning_stream_started:
|
||||
reasoning = result.get("last_reasoning")
|
||||
if reasoning:
|
||||
w = shutil.get_terminal_size().columns
|
||||
@@ -5749,7 +5750,7 @@ class HermesCLI:
|
||||
else:
|
||||
duration_str = f"{seconds}s"
|
||||
|
||||
print(f"Resume this session with:")
|
||||
print("Resume this session with:")
|
||||
print(f" hermes --resume {self.session_id}")
|
||||
print()
|
||||
print(f"Session: {self.session_id}")
|
||||
@@ -6149,10 +6150,6 @@ class HermesCLI:
|
||||
return
|
||||
# Accept the selected completion
|
||||
buf.apply_completion(completion)
|
||||
# If text now looks like "/model provider:", re-trigger completions
|
||||
text = buf.document.text_before_cursor
|
||||
if text.startswith("/model ") and text.endswith(":"):
|
||||
buf.start_completion()
|
||||
elif buf.suggestion and buf.suggestion.text:
|
||||
# No completion menu, but there's a ghost text auto-suggestion — accept it
|
||||
buf.insert_text(buf.suggestion.text)
|
||||
@@ -6381,12 +6378,31 @@ class HermesCLI:
|
||||
When the terminal supports bracketed paste, Ctrl+V / Cmd+V
|
||||
triggers this with the pasted text. We also check the
|
||||
clipboard for an image on every paste event.
|
||||
|
||||
Large pastes (5+ lines) are collapsed to a file reference
|
||||
placeholder while preserving any existing user text in the
|
||||
buffer.
|
||||
"""
|
||||
pasted_text = event.data or ""
|
||||
if self._try_attach_clipboard_image():
|
||||
event.app.invalidate()
|
||||
if pasted_text:
|
||||
event.current_buffer.insert_text(pasted_text)
|
||||
line_count = pasted_text.count('\n')
|
||||
buf = event.current_buffer
|
||||
if line_count >= 5 and not buf.text.strip().startswith('/'):
|
||||
_paste_counter[0] += 1
|
||||
paste_dir = _hermes_home / "pastes"
|
||||
paste_dir.mkdir(parents=True, exist_ok=True)
|
||||
paste_file = paste_dir / f"paste_{_paste_counter[0]}_{datetime.now().strftime('%H%M%S')}.txt"
|
||||
paste_file.write_text(pasted_text, encoding="utf-8")
|
||||
placeholder = f"[Pasted text #{_paste_counter[0]}: {line_count + 1} lines \u2192 {paste_file}]"
|
||||
prefix = ""
|
||||
if buf.cursor_position > 0 and buf.text[buf.cursor_position - 1] != '\n':
|
||||
prefix = "\n"
|
||||
_paste_just_collapsed[0] = True
|
||||
buf.insert_text(prefix + placeholder)
|
||||
else:
|
||||
buf.insert_text(pasted_text)
|
||||
|
||||
@kb.add('c-v')
|
||||
def handle_ctrl_v(event):
|
||||
@@ -6428,36 +6444,9 @@ class HermesCLI:
|
||||
# Create the input area with multiline (shift+enter), autocomplete, and paste handling
|
||||
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
||||
|
||||
def _get_model_completer_info() -> dict:
|
||||
"""Return provider/model info for /model autocomplete."""
|
||||
try:
|
||||
from hermes_cli.models import (
|
||||
_PROVIDER_LABELS, _PROVIDER_MODELS, normalize_provider,
|
||||
provider_model_ids,
|
||||
)
|
||||
current = getattr(cli_ref, "provider", None) or getattr(cli_ref, "requested_provider", "openrouter")
|
||||
current = normalize_provider(current)
|
||||
|
||||
# Provider map: id -> label (only providers with known models)
|
||||
providers = {}
|
||||
for pid, plabel in _PROVIDER_LABELS.items():
|
||||
providers[pid] = plabel
|
||||
|
||||
def models_for(provider_name: str) -> list[str]:
|
||||
norm = normalize_provider(provider_name)
|
||||
return provider_model_ids(norm)
|
||||
|
||||
return {
|
||||
"current_provider": current,
|
||||
"providers": providers,
|
||||
"models_for": models_for,
|
||||
}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
_completer = SlashCommandCompleter(
|
||||
skill_commands_provider=lambda: _skill_commands,
|
||||
model_completer_provider=_get_model_completer_info,
|
||||
)
|
||||
input_area = TextArea(
|
||||
height=Dimension(min=1, max=8, preferred=1),
|
||||
@@ -6500,15 +6489,25 @@ class HermesCLI:
|
||||
# Paste collapsing: detect large pastes and save to temp file
|
||||
_paste_counter = [0]
|
||||
_prev_text_len = [0]
|
||||
_paste_just_collapsed = [False]
|
||||
|
||||
def _on_text_changed(buf):
|
||||
"""Detect large pastes and collapse them to a file reference."""
|
||||
"""Detect large pastes and collapse them to a file reference.
|
||||
|
||||
When bracketed paste is available, handle_paste collapses
|
||||
large pastes directly. This handler is a fallback for
|
||||
terminals without bracketed paste support.
|
||||
"""
|
||||
text = buf.text
|
||||
line_count = text.count('\n')
|
||||
chars_added = len(text) - _prev_text_len[0]
|
||||
_prev_text_len[0] = len(text)
|
||||
if _paste_just_collapsed[0]:
|
||||
_paste_just_collapsed[0] = False
|
||||
return
|
||||
line_count = text.count('\n')
|
||||
# Heuristic: a real paste adds many characters at once (not just a
|
||||
# single newline from Alt+Enter) AND the result has 5+ lines.
|
||||
# Fallback for terminals without bracketed paste support.
|
||||
if line_count >= 5 and chars_added > 1 and not text.startswith('/'):
|
||||
_paste_counter[0] += 1
|
||||
# Save to temp file
|
||||
@@ -6517,7 +6516,7 @@ class HermesCLI:
|
||||
paste_file = paste_dir / f"paste_{_paste_counter[0]}_{datetime.now().strftime('%H%M%S')}.txt"
|
||||
paste_file.write_text(text, encoding="utf-8")
|
||||
# Replace buffer with compact reference
|
||||
buf.text = f"[Pasted text #{_paste_counter[0]}: {line_count + 1} lines → {paste_file}]"
|
||||
buf.text = f"[Pasted text #{_paste_counter[0]}: {line_count + 1} lines \u2192 {paste_file}]"
|
||||
buf.cursor_position = len(buf.text)
|
||||
|
||||
input_area.buffer.on_text_changed += _on_text_changed
|
||||
@@ -7032,23 +7031,33 @@ class HermesCLI:
|
||||
|
||||
# Expand paste references back to full content
|
||||
import re as _re
|
||||
paste_match = _re.match(r'\[Pasted text #\d+: \d+ lines → (.+)\]', user_input) if isinstance(user_input, str) else None
|
||||
if paste_match:
|
||||
paste_path = Path(paste_match.group(1))
|
||||
_paste_ref_re = _re.compile(r'\[Pasted text #\d+: \d+ lines \u2192 (.+?)\]')
|
||||
paste_refs = list(_paste_ref_re.finditer(user_input)) if isinstance(user_input, str) else []
|
||||
if paste_refs:
|
||||
def _expand_ref(m):
|
||||
p = Path(m.group(1))
|
||||
return p.read_text(encoding="utf-8") if p.exists() else m.group(0)
|
||||
expanded = _paste_ref_re.sub(_expand_ref, user_input)
|
||||
total_lines = expanded.count('\n') + 1
|
||||
n_pastes = len(paste_refs)
|
||||
_user_bar = f"[{_accent_hex()}]{'─' * 40}[/]"
|
||||
if paste_path.exists():
|
||||
full_text = paste_path.read_text(encoding="utf-8")
|
||||
line_count = full_text.count('\n') + 1
|
||||
print()
|
||||
ChatConsole().print(_user_bar)
|
||||
print()
|
||||
ChatConsole().print(_user_bar)
|
||||
# Show any surrounding user text alongside the paste summary
|
||||
split_parts = _paste_ref_re.split(user_input)
|
||||
visible_user_text = " ".join(
|
||||
split_parts[i].strip() for i in range(0, len(split_parts), 2) if split_parts[i].strip()
|
||||
)
|
||||
if visible_user_text:
|
||||
ChatConsole().print(
|
||||
f"[bold {_accent_hex()}]●[/] [bold]{_escape(f'[Pasted text: {line_count} lines]')}[/]"
|
||||
f"[bold {_accent_hex()}]\u25cf[/] [bold]{_escape(visible_user_text)}[/] "
|
||||
f"[dim]({n_pastes} pasted block{'s' if n_pastes > 1 else ''}, {total_lines} lines total)[/]"
|
||||
)
|
||||
user_input = full_text
|
||||
else:
|
||||
print()
|
||||
ChatConsole().print(_user_bar)
|
||||
ChatConsole().print(f"[bold {_accent_hex()}]●[/] [bold]{_escape(user_input)}[/]")
|
||||
ChatConsole().print(
|
||||
f"[bold {_accent_hex()}]\u25cf[/] [bold]{_escape(f'[Pasted text: {total_lines} lines]')}[/]"
|
||||
)
|
||||
user_input = expanded
|
||||
else:
|
||||
_user_bar = f"[{_accent_hex()}]{'─' * 40}[/]"
|
||||
if '\n' in user_input:
|
||||
@@ -7119,7 +7128,7 @@ class HermesCLI:
|
||||
if self.agent and self.conversation_history:
|
||||
try:
|
||||
self.agent.flush_memories(self.conversation_history)
|
||||
except Exception:
|
||||
except (Exception, KeyboardInterrupt):
|
||||
pass
|
||||
# Shut down voice recorder (release persistent audio stream)
|
||||
if hasattr(self, '_voice_recorder') and self._voice_recorder:
|
||||
|
||||
+2
-1
@@ -14,6 +14,7 @@ import re
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from hermes_constants import get_hermes_home
|
||||
from typing import Optional, Dict, List, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -30,7 +31,7 @@ except ImportError:
|
||||
# Configuration
|
||||
# =============================================================================
|
||||
|
||||
HERMES_DIR = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||
HERMES_DIR = get_hermes_home()
|
||||
CRON_DIR = HERMES_DIR / "cron"
|
||||
JOBS_FILE = CRON_DIR / "jobs.json"
|
||||
OUTPUT_DIR = CRON_DIR / "output"
|
||||
|
||||
+10
-10
@@ -24,8 +24,8 @@ except ImportError:
|
||||
import msvcrt
|
||||
except ImportError:
|
||||
msvcrt = None
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from hermes_constants import get_hermes_home
|
||||
from typing import Optional
|
||||
|
||||
from hermes_time import now as _hermes_now
|
||||
@@ -43,7 +43,7 @@ from cron.jobs import get_due_jobs, mark_job_run, save_job_output
|
||||
SILENT_MARKER = "[SILENT]"
|
||||
|
||||
# Resolve Hermes home directory (respects HERMES_HOME override)
|
||||
_hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||
_hermes_home = get_hermes_home()
|
||||
|
||||
# File-based lock prevents concurrent ticks from gateway + daemon + systemd timer
|
||||
_LOCK_DIR = _hermes_home / "cron"
|
||||
@@ -280,6 +280,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
job_name = job["name"]
|
||||
prompt = _build_job_prompt(job)
|
||||
origin = _resolve_origin(job)
|
||||
_cron_session_id = f"cron_{job_id}_{_hermes_now().strftime('%Y%m%d_%H%M%S')}"
|
||||
|
||||
logger.info("Running job '%s' (ID: %s)", job_name, job_id)
|
||||
logger.info("Prompt: %s", prompt[:100])
|
||||
@@ -327,16 +328,11 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
logger.warning("Job '%s': failed to load config.yaml, using defaults: %s", job_id, e)
|
||||
|
||||
# Reasoning config from env or config.yaml
|
||||
reasoning_config = None
|
||||
from hermes_constants import parse_reasoning_effort
|
||||
effort = os.getenv("HERMES_REASONING_EFFORT", "")
|
||||
if not effort:
|
||||
effort = str(_cfg.get("agent", {}).get("reasoning_effort", "")).strip()
|
||||
if effort and effort.lower() != "none":
|
||||
valid = ("xhigh", "high", "medium", "low", "minimal")
|
||||
if effort.lower() in valid:
|
||||
reasoning_config = {"enabled": True, "effort": effort.lower()}
|
||||
elif effort.lower() == "none":
|
||||
reasoning_config = {"enabled": False}
|
||||
reasoning_config = parse_reasoning_effort(effort)
|
||||
|
||||
# Prefill messages from env or config.yaml
|
||||
prefill_messages = None
|
||||
@@ -411,7 +407,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
disabled_toolsets=["cronjob", "messaging", "clarify"],
|
||||
quiet_mode=True,
|
||||
platform="cron",
|
||||
session_id=f"cron_{job_id}_{_hermes_now().strftime('%Y%m%d_%H%M%S')}",
|
||||
session_id=_cron_session_id,
|
||||
session_db=_session_db,
|
||||
)
|
||||
|
||||
@@ -476,6 +472,10 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
):
|
||||
os.environ.pop(key, None)
|
||||
if _session_db:
|
||||
try:
|
||||
_session_db.end_session(_cron_session_id, "cron_complete")
|
||||
except Exception as e:
|
||||
logger.debug("Job '%s': failed to end session: %s", job_id, e)
|
||||
try:
|
||||
_session_db.close()
|
||||
except Exception as e:
|
||||
|
||||
Generated
+181
@@ -0,0 +1,181 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-parts": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1772408722,
|
||||
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1751274312,
|
||||
"narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-24.11",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"pyproject-build-systems": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"pyproject-nix": "pyproject-nix",
|
||||
"uv2nix": "uv2nix"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1772555609,
|
||||
"narHash": "sha256-3BA3HnUvJSbHJAlJj6XSy0Jmu7RyP2gyB/0fL7XuEDo=",
|
||||
"owner": "pyproject-nix",
|
||||
"repo": "build-system-pkgs",
|
||||
"rev": "c37f66a953535c394244888598947679af231863",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "pyproject-nix",
|
||||
"repo": "build-system-pkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"pyproject-nix": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"pyproject-build-systems",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1769936401,
|
||||
"narHash": "sha256-kwCOegKLZJM9v/e/7cqwg1p/YjjTAukKPqmxKnAZRgA=",
|
||||
"owner": "nix-community",
|
||||
"repo": "pyproject.nix",
|
||||
"rev": "b0d513eeeebed6d45b4f2e874f9afba2021f7812",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "pyproject.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"pyproject-nix_2": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1772865871,
|
||||
"narHash": "sha256-/ZTSg97aouL0SlPHaokA4r3iuH9QzHVuWPACD2CUCFY=",
|
||||
"owner": "pyproject-nix",
|
||||
"repo": "pyproject.nix",
|
||||
"rev": "e537db02e72d553cea470976b9733581bcf5b3ed",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "pyproject-nix",
|
||||
"repo": "pyproject.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"pyproject-nix_3": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"uv2nix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1771518446,
|
||||
"narHash": "sha256-nFJSfD89vWTu92KyuJWDoTQJuoDuddkJV3TlOl1cOic=",
|
||||
"owner": "pyproject-nix",
|
||||
"repo": "pyproject.nix",
|
||||
"rev": "eb204c6b3335698dec6c7fc1da0ebc3c6df05937",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "pyproject-nix",
|
||||
"repo": "pyproject.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-parts": "flake-parts",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"pyproject-build-systems": "pyproject-build-systems",
|
||||
"pyproject-nix": "pyproject-nix_2",
|
||||
"uv2nix": "uv2nix_2"
|
||||
}
|
||||
},
|
||||
"uv2nix": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"pyproject-build-systems",
|
||||
"nixpkgs"
|
||||
],
|
||||
"pyproject-nix": [
|
||||
"pyproject-build-systems",
|
||||
"pyproject-nix"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1770770348,
|
||||
"narHash": "sha256-A2GzkmzdYvdgmMEu5yxW+xhossP+txrYb7RuzRaqhlg=",
|
||||
"owner": "pyproject-nix",
|
||||
"repo": "uv2nix",
|
||||
"rev": "5d1b2cb4fe3158043fbafbbe2e46238abbc954b0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "pyproject-nix",
|
||||
"repo": "uv2nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"uv2nix_2": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"pyproject-nix": "pyproject-nix_3"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1773039484,
|
||||
"narHash": "sha256-+boo33KYkJDw9KItpeEXXv8+65f7hHv/earxpcyzQ0I=",
|
||||
"owner": "pyproject-nix",
|
||||
"repo": "uv2nix",
|
||||
"rev": "b68be7cfeacbed9a3fa38a2b5adc0cfb81d9bb1f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "pyproject-nix",
|
||||
"repo": "uv2nix",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
description = "Hermes Agent - AI agent framework by Nous Research";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
|
||||
flake-parts = {
|
||||
url = "github:hercules-ci/flake-parts";
|
||||
inputs.nixpkgs-lib.follows = "nixpkgs";
|
||||
};
|
||||
pyproject-nix = {
|
||||
url = "github:pyproject-nix/pyproject.nix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
uv2nix = {
|
||||
url = "github:pyproject-nix/uv2nix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
pyproject-build-systems = {
|
||||
url = "github:pyproject-nix/build-system-pkgs";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
|
||||
outputs = inputs:
|
||||
inputs.flake-parts.lib.mkFlake { inherit inputs; } {
|
||||
systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" ];
|
||||
|
||||
imports = [
|
||||
./nix/packages.nix
|
||||
./nix/nixosModules.nix
|
||||
./nix/checks.nix
|
||||
./nix/devShell.nix
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -9,7 +9,6 @@ action="list" and for resolving human-friendly channel names to numeric IDs.
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
@@ -90,7 +89,7 @@ def _build_discord(adapter) -> List[Dict[str, str]]:
|
||||
return channels
|
||||
|
||||
try:
|
||||
import discord as _discord
|
||||
import discord as _discord # noqa: F401 — SDK presence check
|
||||
except ImportError:
|
||||
return channels
|
||||
|
||||
@@ -119,7 +118,6 @@ def _build_slack(adapter) -> List[Dict[str, str]]:
|
||||
return _build_from_sessions("slack")
|
||||
|
||||
try:
|
||||
import asyncio
|
||||
from tools.send_message_tool import _send_slack # noqa: F401
|
||||
# Use the Slack Web API directly if available
|
||||
except Exception:
|
||||
|
||||
@@ -13,7 +13,6 @@ from pathlib import Path
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional, Any, Union
|
||||
from enum import Enum
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
|
||||
@@ -21,8 +21,6 @@ Errors in hooks are caught and logged but never block the main pipeline.
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
@@ -12,7 +12,6 @@ the full SessionStore machinery.
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
@@ -819,6 +819,16 @@ class BasePlatformAdapter(ABC):
|
||||
await asyncio.sleep(interval)
|
||||
except asyncio.CancelledError:
|
||||
pass # Normal cancellation when handler completes
|
||||
finally:
|
||||
# Ensure the underlying platform typing loop is stopped.
|
||||
# _keep_typing may have called send_typing() after an outer
|
||||
# stop_typing() cleared the task dict, recreating the loop.
|
||||
# Cancelling _keep_typing alone won't clean that up.
|
||||
if hasattr(self, "stop_typing"):
|
||||
try:
|
||||
await self.stop_typing(chat_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def handle_message(self, event: MessageEvent) -> None:
|
||||
"""
|
||||
@@ -1130,6 +1140,13 @@ class BasePlatformAdapter(ABC):
|
||||
await typing_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
# Also cancel any platform-level persistent typing tasks (e.g. Discord)
|
||||
# that may have been recreated by _keep_typing after the last stop_typing()
|
||||
try:
|
||||
if hasattr(self, "stop_typing"):
|
||||
await self.stop_typing(event.source.chat_id)
|
||||
except Exception:
|
||||
pass
|
||||
# Clean up session tracking
|
||||
if session_key in self._active_sessions:
|
||||
del self._active_sessions[session_key]
|
||||
|
||||
@@ -20,7 +20,7 @@ import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, List, Optional, Any
|
||||
from typing import Callable, Dict, Optional, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ import re
|
||||
import smtplib
|
||||
import ssl
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from email.header import decode_header
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
@@ -454,7 +453,6 @@ class EmailAdapter(BasePlatformAdapter):
|
||||
|
||||
async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""Email has no typing indicator — no-op."""
|
||||
pass
|
||||
|
||||
async def send_image(
|
||||
self,
|
||||
|
||||
@@ -19,7 +19,7 @@ import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
from typing import Any, Dict, Optional, Set
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
@@ -435,7 +435,6 @@ class HomeAssistantAdapter(BasePlatformAdapter):
|
||||
|
||||
async def send_typing(self, chat_id: str, metadata=None) -> None:
|
||||
"""No typing indicator for Home Assistant."""
|
||||
pass
|
||||
|
||||
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
||||
"""Return basic info about the HA event channel."""
|
||||
|
||||
@@ -17,14 +17,13 @@ Environment variables:
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
from typing import Any, Dict, Optional, Set
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import (
|
||||
|
||||
@@ -20,7 +20,7 @@ import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import (
|
||||
|
||||
@@ -12,7 +12,7 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Dict, List, Optional, Any
|
||||
from typing import Dict, Optional, Any
|
||||
|
||||
try:
|
||||
from slack_bolt.async_app import AsyncApp
|
||||
@@ -37,8 +37,6 @@ from gateway.platforms.base import (
|
||||
SendResult,
|
||||
SUPPORTED_DOCUMENT_TYPES,
|
||||
cache_document_from_bytes,
|
||||
cache_image_from_url,
|
||||
cache_audio_from_url,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -17,12 +17,11 @@ Gateway-specific env vars:
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import urllib.parse
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import (
|
||||
|
||||
@@ -11,7 +11,7 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Dict, List, Optional, Any
|
||||
from typing import Dict, Optional, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ with different backends via a bridge pattern.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
@@ -24,7 +23,7 @@ import subprocess
|
||||
|
||||
_IS_WINDOWS = platform.system() == "Windows"
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
from typing import Dict, Optional, Any
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
|
||||
+69
-222
@@ -76,7 +76,8 @@ _ensure_ssl_certs()
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
# Resolve Hermes home directory (respects HERMES_HOME override)
|
||||
_hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||
from hermes_constants import get_hermes_home
|
||||
_hermes_home = get_hermes_home()
|
||||
|
||||
# Load environment variables from ~/.hermes/.env first.
|
||||
# User-managed env files should override stale shell exports on restart.
|
||||
@@ -220,7 +221,7 @@ from gateway.session import (
|
||||
build_session_context_prompt,
|
||||
build_session_key,
|
||||
)
|
||||
from gateway.delivery import DeliveryRouter, DeliveryTarget
|
||||
from gateway.delivery import DeliveryRouter
|
||||
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -805,6 +806,7 @@ class GatewayRunner:
|
||||
"medium", "low", "minimal", "none". Returns None to use default
|
||||
(medium).
|
||||
"""
|
||||
from hermes_constants import parse_reasoning_effort
|
||||
effort = ""
|
||||
try:
|
||||
import yaml as _y
|
||||
@@ -817,16 +819,10 @@ class GatewayRunner:
|
||||
pass
|
||||
if not effort:
|
||||
effort = os.getenv("HERMES_REASONING_EFFORT", "")
|
||||
if not effort:
|
||||
return None
|
||||
effort = effort.lower().strip()
|
||||
if effort == "none":
|
||||
return {"enabled": False}
|
||||
valid = ("xhigh", "high", "medium", "low", "minimal")
|
||||
if effort in valid:
|
||||
return {"enabled": True, "effort": effort}
|
||||
logger.warning("Unknown reasoning_effort '%s', using default (medium)", effort)
|
||||
return None
|
||||
result = parse_reasoning_effort(effort)
|
||||
if effort and effort.strip() and result is None:
|
||||
logger.warning("Unknown reasoning_effort '%s', using default (medium)", effort)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _load_show_reasoning() -> bool:
|
||||
@@ -1560,6 +1556,30 @@ class GatewayRunner:
|
||||
if event.get_command() == "status":
|
||||
return await self._handle_status_command(event)
|
||||
|
||||
# Resolve the command once for all early-intercept checks below.
|
||||
from hermes_cli.commands import resolve_command as _resolve_cmd_inner
|
||||
_evt_cmd = event.get_command()
|
||||
_cmd_def_inner = _resolve_cmd_inner(_evt_cmd) if _evt_cmd else None
|
||||
|
||||
# /stop must hard-kill the session when an agent is running.
|
||||
# A soft interrupt (agent.interrupt()) doesn't help when the agent
|
||||
# is truly hung — the executor thread is blocked and never checks
|
||||
# _interrupt_requested. Force-clean _running_agents so the session
|
||||
# is unlocked and subsequent messages are processed normally.
|
||||
if _cmd_def_inner and _cmd_def_inner.name == "stop":
|
||||
running_agent = self._running_agents.get(_quick_key)
|
||||
if running_agent and running_agent is not _AGENT_PENDING_SENTINEL:
|
||||
running_agent.interrupt("Stop requested")
|
||||
# Force-clean: remove the session lock regardless of agent state
|
||||
adapter = self.adapters.get(source.platform)
|
||||
if adapter and hasattr(adapter, 'get_pending_message'):
|
||||
adapter.get_pending_message(_quick_key) # consume and discard
|
||||
self._pending_messages.pop(_quick_key, None)
|
||||
if _quick_key in self._running_agents:
|
||||
del self._running_agents[_quick_key]
|
||||
logger.info("HARD STOP for session %s — session lock released", _quick_key[:20])
|
||||
return "⚡ Force-stopped. The session is unlocked — you can send a new message."
|
||||
|
||||
# /reset and /new must bypass the running-agent guard so they
|
||||
# actually dispatch as commands instead of being queued as user
|
||||
# text (which would be fed back to the agent with the same
|
||||
@@ -1567,9 +1587,6 @@ class GatewayRunner:
|
||||
# clear the adapter's pending queue so the stale "/reset" text
|
||||
# doesn't get re-processed as a user message after the
|
||||
# interrupt completes.
|
||||
from hermes_cli.commands import resolve_command as _resolve_cmd_inner
|
||||
_evt_cmd = event.get_command()
|
||||
_cmd_def_inner = _resolve_cmd_inner(_evt_cmd) if _evt_cmd else None
|
||||
if _cmd_def_inner and _cmd_def_inner.name == "new":
|
||||
running_agent = self._running_agents.get(_quick_key)
|
||||
if running_agent and running_agent is not _AGENT_PENDING_SENTINEL:
|
||||
@@ -1627,8 +1644,11 @@ class GatewayRunner:
|
||||
if running_agent is _AGENT_PENDING_SENTINEL:
|
||||
# Agent is being set up but not ready yet.
|
||||
if event.get_command() == "stop":
|
||||
# Nothing to interrupt — agent hasn't started yet.
|
||||
return "⏳ The agent is still starting up — nothing to stop yet."
|
||||
# Force-clean the sentinel so the session is unlocked.
|
||||
if _quick_key in self._running_agents:
|
||||
del self._running_agents[_quick_key]
|
||||
logger.info("HARD STOP (pending) for session %s — sentinel cleared", _quick_key[:20])
|
||||
return "⚡ Force-stopped. The agent was still starting — session unlocked."
|
||||
# Queue the message so it will be picked up after the
|
||||
# agent starts.
|
||||
adapter = self.adapters.get(source.platform)
|
||||
@@ -1674,9 +1694,6 @@ class GatewayRunner:
|
||||
if canonical == "stop":
|
||||
return await self._handle_stop_command(event)
|
||||
|
||||
if canonical == "model":
|
||||
return await self._handle_model_command(event)
|
||||
|
||||
if canonical == "reasoning":
|
||||
return await self._handle_reasoning_command(event)
|
||||
|
||||
@@ -2394,7 +2411,8 @@ class GatewayRunner:
|
||||
history=history,
|
||||
source=source,
|
||||
session_id=session_entry.session_id,
|
||||
session_key=session_key
|
||||
session_key=session_key,
|
||||
event_message_id=event.message_id,
|
||||
)
|
||||
|
||||
# Stop persistent typing indicator now that the agent is done
|
||||
@@ -2735,17 +2753,32 @@ class GatewayRunner:
|
||||
return "\n".join(lines)
|
||||
|
||||
async def _handle_stop_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /stop command - interrupt a running agent."""
|
||||
"""Handle /stop command - interrupt a running agent.
|
||||
|
||||
When an agent is truly hung (blocked thread that never checks
|
||||
_interrupt_requested), the early intercept in _handle_message()
|
||||
handles /stop before this method is reached. This handler fires
|
||||
only through normal command dispatch (no running agent) or as a
|
||||
fallback. Force-clean the session lock in all cases for safety.
|
||||
"""
|
||||
source = event.source
|
||||
session_entry = self.session_store.get_or_create_session(source)
|
||||
session_key = session_entry.session_key
|
||||
|
||||
agent = self._running_agents.get(session_key)
|
||||
if agent is _AGENT_PENDING_SENTINEL:
|
||||
return "⏳ The agent is still starting up — nothing to stop yet."
|
||||
# Force-clean the sentinel so the session is unlocked.
|
||||
if session_key in self._running_agents:
|
||||
del self._running_agents[session_key]
|
||||
logger.info("HARD STOP (pending) for session %s — sentinel cleared", session_key[:20])
|
||||
return "⚡ Force-stopped. The agent was still starting — session unlocked."
|
||||
if agent:
|
||||
agent.interrupt()
|
||||
return "⚡ Stopping the current task... The agent will finish its current step and respond."
|
||||
agent.interrupt("Stop requested")
|
||||
# Force-clean the session lock so a truly hung agent doesn't
|
||||
# keep it locked forever.
|
||||
if session_key in self._running_agents:
|
||||
del self._running_agents[session_key]
|
||||
return "⚡ Force-stopped. The session is unlocked — you can send a new message."
|
||||
else:
|
||||
return "No active task to stop."
|
||||
|
||||
@@ -2767,198 +2800,6 @@ class GatewayRunner:
|
||||
pass
|
||||
return "\n".join(lines)
|
||||
|
||||
async def _handle_model_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /model command - show or change the current model."""
|
||||
import yaml
|
||||
from hermes_cli.models import (
|
||||
parse_model_input,
|
||||
validate_requested_model,
|
||||
curated_models_for_provider,
|
||||
normalize_provider,
|
||||
_PROVIDER_LABELS,
|
||||
)
|
||||
|
||||
args = event.get_command_args().strip()
|
||||
config_path = _hermes_home / 'config.yaml'
|
||||
|
||||
# Resolve current model and provider from config
|
||||
current = os.getenv("HERMES_MODEL") or "anthropic/claude-opus-4.6"
|
||||
current_provider = "openrouter"
|
||||
try:
|
||||
if config_path.exists():
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, str):
|
||||
current = model_cfg
|
||||
elif isinstance(model_cfg, dict):
|
||||
current = model_cfg.get("default", current)
|
||||
current_provider = model_cfg.get("provider", current_provider)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Resolve "auto" to the actual provider using credential detection
|
||||
current_provider = normalize_provider(current_provider)
|
||||
if current_provider == "auto":
|
||||
try:
|
||||
from hermes_cli.auth import resolve_provider as _resolve_provider
|
||||
current_provider = _resolve_provider(current_provider)
|
||||
except Exception:
|
||||
current_provider = "openrouter"
|
||||
|
||||
# Detect custom endpoint: provider resolved to openrouter but a custom
|
||||
# base URL is configured — the user set up a custom endpoint.
|
||||
if current_provider == "openrouter" and os.getenv("OPENAI_BASE_URL", "").strip():
|
||||
current_provider = "custom"
|
||||
|
||||
if not args:
|
||||
# If a fallback model is active, show it instead of config
|
||||
if self._effective_model:
|
||||
eff_provider = self._effective_provider or 'unknown'
|
||||
eff_label = _PROVIDER_LABELS.get(eff_provider, eff_provider)
|
||||
cfg_label = _PROVIDER_LABELS.get(current_provider, current_provider)
|
||||
lines = [
|
||||
f"🤖 **Active model:** `{self._effective_model}` (fallback)",
|
||||
f"**Provider:** {eff_label}",
|
||||
f"**Primary model** (`{current}` via {cfg_label}) is rate-limited.",
|
||||
"",
|
||||
]
|
||||
lines.append("To change: `/model model-name`")
|
||||
lines.append("Switch provider: `/model provider:model-name`")
|
||||
return "\n".join(lines)
|
||||
|
||||
provider_label = _PROVIDER_LABELS.get(current_provider, current_provider)
|
||||
lines = [
|
||||
f"🤖 **Current model:** `{current}`",
|
||||
f"**Provider:** {provider_label}",
|
||||
]
|
||||
# Show custom endpoint URL when using a custom provider
|
||||
if current_provider == "custom":
|
||||
from hermes_cli.models import _get_custom_base_url
|
||||
custom_url = _get_custom_base_url() or os.getenv("OPENAI_BASE_URL", "")
|
||||
if custom_url:
|
||||
lines.append(f"**Endpoint:** `{custom_url}`")
|
||||
lines.append("")
|
||||
curated = curated_models_for_provider(current_provider)
|
||||
if curated:
|
||||
lines.append(f"**Available models ({provider_label}):**")
|
||||
for mid, desc in curated:
|
||||
marker = " ←" if mid == current else ""
|
||||
label = f" _{desc}_" if desc else ""
|
||||
lines.append(f"• `{mid}`{label}{marker}")
|
||||
lines.append("")
|
||||
lines.append("To change: `/model model-name`")
|
||||
lines.append("Switch provider: `/model provider-name` or `/model provider:model-name`")
|
||||
return "\n".join(lines)
|
||||
|
||||
# Handle bare "/model custom" — switch to custom provider
|
||||
# and auto-detect the model from the endpoint.
|
||||
if args.strip().lower() == "custom":
|
||||
from hermes_cli.model_switch import switch_to_custom_provider
|
||||
cust_result = switch_to_custom_provider()
|
||||
if not cust_result.success:
|
||||
return f"⚠️ {cust_result.error_message}"
|
||||
try:
|
||||
user_config = {}
|
||||
if config_path.exists():
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
user_config = yaml.safe_load(f) or {}
|
||||
if "model" not in user_config or not isinstance(user_config["model"], dict):
|
||||
user_config["model"] = {}
|
||||
user_config["model"]["default"] = cust_result.model
|
||||
user_config["model"]["provider"] = "custom"
|
||||
user_config["model"]["base_url"] = cust_result.base_url
|
||||
with open(config_path, 'w', encoding="utf-8") as f:
|
||||
yaml.dump(user_config, f, default_flow_style=False, sort_keys=False)
|
||||
except Exception as e:
|
||||
return f"⚠️ Failed to save model change: {e}"
|
||||
os.environ["HERMES_MODEL"] = cust_result.model
|
||||
os.environ["HERMES_INFERENCE_PROVIDER"] = "custom"
|
||||
self._effective_model = None
|
||||
self._effective_provider = None
|
||||
return (
|
||||
f"🤖 Model changed to `{cust_result.model}` (saved to config)\n"
|
||||
f"**Provider:** Custom\n"
|
||||
f"**Endpoint:** `{cust_result.base_url}`\n"
|
||||
f"_Model auto-detected from endpoint. Takes effect on next message._"
|
||||
)
|
||||
|
||||
# Core model-switching pipeline (shared with CLI)
|
||||
from hermes_cli.model_switch import switch_model
|
||||
|
||||
# Resolve current base_url for is_custom detection
|
||||
_resolved_base = ""
|
||||
try:
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider as _rtp
|
||||
_resolved_base = _rtp(requested=current_provider).get("base_url", "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
result = switch_model(
|
||||
args,
|
||||
current_provider,
|
||||
current_base_url=_resolved_base,
|
||||
current_api_key=os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENAI_API_KEY") or "",
|
||||
)
|
||||
|
||||
if not result.success:
|
||||
msg = result.error_message
|
||||
tip = "\n\nUse `/model` to see available models, `/provider` to see providers" if "Did you mean" not in msg else ""
|
||||
return f"⚠️ {msg}{tip}"
|
||||
|
||||
# Persist to config only if validation approves
|
||||
if result.persist:
|
||||
try:
|
||||
user_config = {}
|
||||
if config_path.exists():
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
user_config = yaml.safe_load(f) or {}
|
||||
if "model" not in user_config or not isinstance(user_config["model"], dict):
|
||||
user_config["model"] = {}
|
||||
user_config["model"]["default"] = result.new_model
|
||||
if result.provider_changed:
|
||||
user_config["model"]["provider"] = result.target_provider
|
||||
# Persist base_url for custom endpoints; clear when
|
||||
# switching away from custom (#2562 Phase 2).
|
||||
if result.base_url and "openrouter.ai" not in (result.base_url or ""):
|
||||
user_config["model"]["base_url"] = result.base_url
|
||||
else:
|
||||
user_config["model"].pop("base_url", None)
|
||||
with open(config_path, 'w', encoding="utf-8") as f:
|
||||
yaml.dump(user_config, f, default_flow_style=False, sort_keys=False)
|
||||
except Exception as e:
|
||||
return f"⚠️ Failed to save model change: {e}"
|
||||
|
||||
# Set env vars so the next agent run picks up the change
|
||||
os.environ["HERMES_MODEL"] = result.new_model
|
||||
if result.provider_changed:
|
||||
os.environ["HERMES_INFERENCE_PROVIDER"] = result.target_provider
|
||||
|
||||
provider_note = f"\n**Provider:** {result.provider_label}" if result.provider_changed else ""
|
||||
|
||||
warning = ""
|
||||
if result.warning_message:
|
||||
warning = f"\n⚠️ {result.warning_message}"
|
||||
|
||||
persist_note = "saved to config" if result.persist else "this session only — will revert on restart"
|
||||
|
||||
# Clear fallback state since user explicitly chose a model
|
||||
self._effective_model = None
|
||||
self._effective_provider = None
|
||||
|
||||
# Show endpoint info for custom providers
|
||||
custom_hint = ""
|
||||
if result.is_custom_target:
|
||||
endpoint = result.base_url or _resolved_base or "custom endpoint"
|
||||
custom_hint = f"\n**Endpoint:** `{endpoint}`"
|
||||
if not result.provider_changed:
|
||||
custom_hint += (
|
||||
"\n_To switch providers, use_ `/model provider:model`"
|
||||
"\n_e.g._ `/model openrouter:anthropic/claude-sonnet-4`"
|
||||
)
|
||||
|
||||
return f"🤖 Model changed to `{result.new_model}` ({persist_note}){provider_note}{warning}{custom_hint}\n_(takes effect on next message)_"
|
||||
|
||||
async def _handle_provider_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /provider command - show available providers."""
|
||||
import yaml
|
||||
@@ -3044,7 +2885,7 @@ class GatewayRunner:
|
||||
else:
|
||||
preview = prompt[:50] + "..." if len(prompt) > 50 else prompt
|
||||
lines.append(f"• `{name}` — {preview}")
|
||||
lines.append(f"\nUsage: `/personality <name>`")
|
||||
lines.append("\nUsage: `/personality <name>`")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _resolve_prompt(value):
|
||||
@@ -4844,6 +4685,7 @@ class GatewayRunner:
|
||||
session_id: str,
|
||||
session_key: str = None,
|
||||
_interrupt_depth: int = 0,
|
||||
event_message_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Run the agent with the given message and context.
|
||||
@@ -4980,7 +4822,12 @@ class GatewayRunner:
|
||||
|
||||
# Background task to send progress messages
|
||||
# Accumulates tool lines into a single message that gets edited
|
||||
_progress_metadata = {"thread_id": source.thread_id} if source.thread_id else None
|
||||
# For DM top-level Slack messages, source.thread_id is None but the
|
||||
# final reply will be threaded under the original message via reply_to.
|
||||
# Use event_message_id as fallback so progress messages land in the
|
||||
# same thread as the final response instead of going to the DM root.
|
||||
_progress_thread_id = source.thread_id or event_message_id
|
||||
_progress_metadata = {"thread_id": _progress_thread_id} if _progress_thread_id else None
|
||||
|
||||
async def send_progress_messages():
|
||||
if not progress_queue:
|
||||
@@ -5095,7 +4942,7 @@ class GatewayRunner:
|
||||
# Bridge sync status_callback → async adapter.send for context pressure
|
||||
_status_adapter = self.adapters.get(source.platform)
|
||||
_status_chat_id = source.chat_id
|
||||
_status_thread_metadata = {"thread_id": source.thread_id} if source.thread_id else None
|
||||
_status_thread_metadata = {"thread_id": _progress_thread_id} if _progress_thread_id else None
|
||||
|
||||
def _status_callback_sync(event_type: str, message: str) -> None:
|
||||
if not _status_adapter:
|
||||
@@ -5176,7 +5023,7 @@ class GatewayRunner:
|
||||
adapter=_adapter,
|
||||
chat_id=source.chat_id,
|
||||
config=_consumer_cfg,
|
||||
metadata={"thread_id": source.thread_id} if source.thread_id else None,
|
||||
metadata={"thread_id": _progress_thread_id} if _progress_thread_id else None,
|
||||
)
|
||||
_stream_delta_cb = _stream_consumer.on_delta
|
||||
stream_consumer_holder[0] = _stream_consumer
|
||||
@@ -5738,7 +5585,7 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
hermes_home = os.getenv("HERMES_HOME", "~/.hermes")
|
||||
hermes_home = str(get_hermes_home())
|
||||
logger.error(
|
||||
"Another gateway instance is already running (PID %d, HERMES_HOME=%s). "
|
||||
"Use 'hermes gateway restart' to replace it, or 'hermes gateway stop' first.",
|
||||
|
||||
+223
-194
@@ -13,15 +13,21 @@ import logging
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
"""Return the current local time."""
|
||||
return datetime.now()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PII redaction helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -59,7 +65,7 @@ def _looks_like_phone(value: str) -> bool:
|
||||
from .config import (
|
||||
Platform,
|
||||
GatewayConfig,
|
||||
SessionResetPolicy,
|
||||
SessionResetPolicy, # noqa: F401 — re-exported via gateway/__init__.py
|
||||
HomeChannel,
|
||||
)
|
||||
|
||||
@@ -471,6 +477,7 @@ class SessionStore:
|
||||
self.config = config
|
||||
self._entries: Dict[str, SessionEntry] = {}
|
||||
self._loaded = False
|
||||
self._lock = threading.Lock()
|
||||
self._has_active_processes_fn = has_active_processes_fn
|
||||
# on_auto_reset is deprecated — memory flush now runs proactively
|
||||
# via the background session expiry watcher in GatewayRunner.
|
||||
@@ -486,12 +493,17 @@ class SessionStore:
|
||||
|
||||
def _ensure_loaded(self) -> None:
|
||||
"""Load sessions index from disk if not already loaded."""
|
||||
with self._lock:
|
||||
self._ensure_loaded_locked()
|
||||
|
||||
def _ensure_loaded_locked(self) -> None:
|
||||
"""Load sessions index from disk. Must be called with self._lock held."""
|
||||
if self._loaded:
|
||||
return
|
||||
|
||||
|
||||
self.sessions_dir.mkdir(parents=True, exist_ok=True)
|
||||
sessions_file = self.sessions_dir / "sessions.json"
|
||||
|
||||
|
||||
if sessions_file.exists():
|
||||
try:
|
||||
with open(sessions_file, "r", encoding="utf-8") as f:
|
||||
@@ -504,7 +516,7 @@ class SessionStore:
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"[gateway] Warning: Failed to load sessions: {e}")
|
||||
|
||||
|
||||
self._loaded = True
|
||||
|
||||
def _save(self) -> None:
|
||||
@@ -556,7 +568,7 @@ class SessionStore:
|
||||
if policy.mode == "none":
|
||||
return False
|
||||
|
||||
now = datetime.now()
|
||||
now = _now()
|
||||
|
||||
if policy.mode in ("idle", "both"):
|
||||
idle_deadline = entry.updated_at + timedelta(minutes=policy.idle_minutes)
|
||||
@@ -597,7 +609,7 @@ class SessionStore:
|
||||
if policy.mode == "none":
|
||||
return None
|
||||
|
||||
now = datetime.now()
|
||||
now = _now()
|
||||
|
||||
if policy.mode in ("idle", "both"):
|
||||
idle_deadline = entry.updated_at + timedelta(minutes=policy.idle_minutes)
|
||||
@@ -637,87 +649,97 @@ class SessionStore:
|
||||
pass # fall through to heuristic
|
||||
# Fallback: check if sessions.json was loaded with existing data.
|
||||
# This covers the rare case where the DB is unavailable.
|
||||
self._ensure_loaded()
|
||||
return len(self._entries) > 1
|
||||
|
||||
with self._lock:
|
||||
self._ensure_loaded_locked()
|
||||
return len(self._entries) > 1
|
||||
|
||||
def get_or_create_session(
|
||||
self,
|
||||
self,
|
||||
source: SessionSource,
|
||||
force_new: bool = False
|
||||
) -> SessionEntry:
|
||||
"""
|
||||
Get an existing session or create a new one.
|
||||
|
||||
|
||||
Evaluates reset policy to determine if the existing session is stale.
|
||||
Creates a session record in SQLite when a new session starts.
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
|
||||
session_key = self._generate_session_key(source)
|
||||
now = datetime.now()
|
||||
|
||||
if session_key in self._entries and not force_new:
|
||||
entry = self._entries[session_key]
|
||||
|
||||
reset_reason = self._should_reset(entry, source)
|
||||
if not reset_reason:
|
||||
entry.updated_at = now
|
||||
self._save()
|
||||
return entry
|
||||
now = _now()
|
||||
|
||||
# SQLite calls are made outside the lock to avoid holding it during I/O.
|
||||
# All _entries / _loaded mutations are protected by self._lock.
|
||||
db_end_session_id = None
|
||||
db_create_kwargs = None
|
||||
|
||||
with self._lock:
|
||||
self._ensure_loaded_locked()
|
||||
|
||||
if session_key in self._entries and not force_new:
|
||||
entry = self._entries[session_key]
|
||||
|
||||
reset_reason = self._should_reset(entry, source)
|
||||
if not reset_reason:
|
||||
entry.updated_at = now
|
||||
self._save()
|
||||
return entry
|
||||
else:
|
||||
# Session is being auto-reset. The background expiry watcher
|
||||
# should have already flushed memories proactively; discard
|
||||
# the marker so it doesn't accumulate.
|
||||
was_auto_reset = True
|
||||
auto_reset_reason = reset_reason
|
||||
# Track whether the expired session had any real conversation
|
||||
reset_had_activity = entry.total_tokens > 0
|
||||
db_end_session_id = entry.session_id
|
||||
self._pre_flushed_sessions.discard(entry.session_id)
|
||||
else:
|
||||
# Session is being auto-reset. The background expiry watcher
|
||||
# should have already flushed memories proactively; discard
|
||||
# the marker so it doesn't accumulate.
|
||||
was_auto_reset = True
|
||||
auto_reset_reason = reset_reason
|
||||
# Track whether the expired session had any real conversation
|
||||
reset_had_activity = entry.total_tokens > 0
|
||||
self._pre_flushed_sessions.discard(entry.session_id)
|
||||
if self._db:
|
||||
try:
|
||||
self._db.end_session(entry.session_id, "session_reset")
|
||||
except Exception as e:
|
||||
logger.debug("Session DB operation failed: %s", e)
|
||||
else:
|
||||
was_auto_reset = False
|
||||
auto_reset_reason = None
|
||||
reset_had_activity = False
|
||||
|
||||
# Create new session
|
||||
session_id = f"{now.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
entry = SessionEntry(
|
||||
session_key=session_key,
|
||||
session_id=session_id,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
origin=source,
|
||||
display_name=source.chat_name,
|
||||
platform=source.platform,
|
||||
chat_type=source.chat_type,
|
||||
was_auto_reset=was_auto_reset,
|
||||
auto_reset_reason=auto_reset_reason,
|
||||
reset_had_activity=reset_had_activity,
|
||||
)
|
||||
|
||||
self._entries[session_key] = entry
|
||||
self._save()
|
||||
|
||||
# Create session in SQLite
|
||||
if self._db:
|
||||
was_auto_reset = False
|
||||
auto_reset_reason = None
|
||||
reset_had_activity = False
|
||||
|
||||
# Create new session
|
||||
session_id = f"{now.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
entry = SessionEntry(
|
||||
session_key=session_key,
|
||||
session_id=session_id,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
origin=source,
|
||||
display_name=source.chat_name,
|
||||
platform=source.platform,
|
||||
chat_type=source.chat_type,
|
||||
was_auto_reset=was_auto_reset,
|
||||
auto_reset_reason=auto_reset_reason,
|
||||
reset_had_activity=reset_had_activity,
|
||||
)
|
||||
|
||||
self._entries[session_key] = entry
|
||||
self._save()
|
||||
db_create_kwargs = {
|
||||
"session_id": session_id,
|
||||
"source": source.platform.value,
|
||||
"user_id": source.user_id,
|
||||
}
|
||||
|
||||
# SQLite operations outside the lock
|
||||
if self._db and db_end_session_id:
|
||||
try:
|
||||
self._db.create_session(
|
||||
session_id=session_id,
|
||||
source=source.platform.value,
|
||||
user_id=source.user_id,
|
||||
)
|
||||
self._db.end_session(db_end_session_id, "session_reset")
|
||||
except Exception as e:
|
||||
logger.debug("Session DB operation failed: %s", e)
|
||||
|
||||
if self._db and db_create_kwargs:
|
||||
try:
|
||||
self._db.create_session(**db_create_kwargs)
|
||||
except Exception as e:
|
||||
print(f"[gateway] Warning: Failed to create SQLite session: {e}")
|
||||
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
def update_session(
|
||||
self,
|
||||
self,
|
||||
session_key: str,
|
||||
input_tokens: int = 0,
|
||||
output_tokens: int = 0,
|
||||
@@ -732,91 +754,100 @@ class SessionStore:
|
||||
base_url: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Update a session's metadata after an interaction."""
|
||||
self._ensure_loaded()
|
||||
|
||||
if session_key in self._entries:
|
||||
entry = self._entries[session_key]
|
||||
entry.updated_at = datetime.now()
|
||||
entry.input_tokens += input_tokens
|
||||
entry.output_tokens += output_tokens
|
||||
entry.cache_read_tokens += cache_read_tokens
|
||||
entry.cache_write_tokens += cache_write_tokens
|
||||
if last_prompt_tokens is not None:
|
||||
entry.last_prompt_tokens = last_prompt_tokens
|
||||
if estimated_cost_usd is not None:
|
||||
entry.estimated_cost_usd += estimated_cost_usd
|
||||
if cost_status:
|
||||
entry.cost_status = cost_status
|
||||
entry.total_tokens = (
|
||||
entry.input_tokens
|
||||
+ entry.output_tokens
|
||||
+ entry.cache_read_tokens
|
||||
+ entry.cache_write_tokens
|
||||
)
|
||||
self._save()
|
||||
|
||||
if self._db:
|
||||
try:
|
||||
self._db.update_token_counts(
|
||||
entry.session_id,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
cache_read_tokens=cache_read_tokens,
|
||||
cache_write_tokens=cache_write_tokens,
|
||||
estimated_cost_usd=estimated_cost_usd,
|
||||
cost_status=cost_status,
|
||||
cost_source=cost_source,
|
||||
billing_provider=provider,
|
||||
billing_base_url=base_url,
|
||||
model=model,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Session DB operation failed: %s", e)
|
||||
|
||||
def reset_session(self, session_key: str) -> Optional[SessionEntry]:
|
||||
"""Force reset a session, creating a new session ID."""
|
||||
self._ensure_loaded()
|
||||
|
||||
if session_key not in self._entries:
|
||||
return None
|
||||
|
||||
old_entry = self._entries[session_key]
|
||||
|
||||
# End old session in SQLite
|
||||
if self._db:
|
||||
db_session_id = None
|
||||
|
||||
with self._lock:
|
||||
self._ensure_loaded_locked()
|
||||
|
||||
if session_key in self._entries:
|
||||
entry = self._entries[session_key]
|
||||
entry.updated_at = _now()
|
||||
entry.input_tokens += input_tokens
|
||||
entry.output_tokens += output_tokens
|
||||
entry.cache_read_tokens += cache_read_tokens
|
||||
entry.cache_write_tokens += cache_write_tokens
|
||||
if last_prompt_tokens is not None:
|
||||
entry.last_prompt_tokens = last_prompt_tokens
|
||||
if estimated_cost_usd is not None:
|
||||
entry.estimated_cost_usd += estimated_cost_usd
|
||||
if cost_status:
|
||||
entry.cost_status = cost_status
|
||||
entry.total_tokens = (
|
||||
entry.input_tokens
|
||||
+ entry.output_tokens
|
||||
+ entry.cache_read_tokens
|
||||
+ entry.cache_write_tokens
|
||||
)
|
||||
self._save()
|
||||
db_session_id = entry.session_id
|
||||
|
||||
if self._db and db_session_id:
|
||||
try:
|
||||
self._db.end_session(old_entry.session_id, "session_reset")
|
||||
except Exception as e:
|
||||
logger.debug("Session DB operation failed: %s", e)
|
||||
|
||||
now = datetime.now()
|
||||
session_id = f"{now.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
new_entry = SessionEntry(
|
||||
session_key=session_key,
|
||||
session_id=session_id,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
origin=old_entry.origin,
|
||||
display_name=old_entry.display_name,
|
||||
platform=old_entry.platform,
|
||||
chat_type=old_entry.chat_type,
|
||||
)
|
||||
|
||||
self._entries[session_key] = new_entry
|
||||
self._save()
|
||||
|
||||
# Create new session in SQLite
|
||||
if self._db:
|
||||
try:
|
||||
self._db.create_session(
|
||||
session_id=session_id,
|
||||
source=old_entry.platform.value if old_entry.platform else "unknown",
|
||||
user_id=old_entry.origin.user_id if old_entry.origin else None,
|
||||
self._db.update_token_counts(
|
||||
db_session_id,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
cache_read_tokens=cache_read_tokens,
|
||||
cache_write_tokens=cache_write_tokens,
|
||||
estimated_cost_usd=estimated_cost_usd,
|
||||
cost_status=cost_status,
|
||||
cost_source=cost_source,
|
||||
billing_provider=provider,
|
||||
billing_base_url=base_url,
|
||||
model=model,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Session DB operation failed: %s", e)
|
||||
|
||||
|
||||
def reset_session(self, session_key: str) -> Optional[SessionEntry]:
|
||||
"""Force reset a session, creating a new session ID."""
|
||||
db_end_session_id = None
|
||||
db_create_kwargs = None
|
||||
new_entry = None
|
||||
|
||||
with self._lock:
|
||||
self._ensure_loaded_locked()
|
||||
|
||||
if session_key not in self._entries:
|
||||
return None
|
||||
|
||||
old_entry = self._entries[session_key]
|
||||
db_end_session_id = old_entry.session_id
|
||||
|
||||
now = _now()
|
||||
session_id = f"{now.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
new_entry = SessionEntry(
|
||||
session_key=session_key,
|
||||
session_id=session_id,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
origin=old_entry.origin,
|
||||
display_name=old_entry.display_name,
|
||||
platform=old_entry.platform,
|
||||
chat_type=old_entry.chat_type,
|
||||
)
|
||||
|
||||
self._entries[session_key] = new_entry
|
||||
self._save()
|
||||
db_create_kwargs = {
|
||||
"session_id": session_id,
|
||||
"source": old_entry.platform.value if old_entry.platform else "unknown",
|
||||
"user_id": old_entry.origin.user_id if old_entry.origin else None,
|
||||
}
|
||||
|
||||
if self._db and db_end_session_id:
|
||||
try:
|
||||
self._db.end_session(db_end_session_id, "session_reset")
|
||||
except Exception as e:
|
||||
logger.debug("Session DB operation failed: %s", e)
|
||||
|
||||
if self._db and db_create_kwargs:
|
||||
try:
|
||||
self._db.create_session(**db_create_kwargs)
|
||||
except Exception as e:
|
||||
logger.debug("Session DB operation failed: %s", e)
|
||||
|
||||
return new_entry
|
||||
|
||||
def switch_session(self, session_key: str, target_session_id: str) -> Optional[SessionEntry]:
|
||||
@@ -827,52 +858,58 @@ class SessionStore:
|
||||
generating a fresh session ID, re-uses ``target_session_id`` so the
|
||||
old transcript is loaded on the next message.
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
db_end_session_id = None
|
||||
new_entry = None
|
||||
|
||||
if session_key not in self._entries:
|
||||
return None
|
||||
with self._lock:
|
||||
self._ensure_loaded_locked()
|
||||
|
||||
old_entry = self._entries[session_key]
|
||||
if session_key not in self._entries:
|
||||
return None
|
||||
|
||||
# Don't switch if already on that session
|
||||
if old_entry.session_id == target_session_id:
|
||||
return old_entry
|
||||
old_entry = self._entries[session_key]
|
||||
|
||||
# End the current session in SQLite
|
||||
if self._db:
|
||||
# Don't switch if already on that session
|
||||
if old_entry.session_id == target_session_id:
|
||||
return old_entry
|
||||
|
||||
db_end_session_id = old_entry.session_id
|
||||
|
||||
now = _now()
|
||||
new_entry = SessionEntry(
|
||||
session_key=session_key,
|
||||
session_id=target_session_id,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
origin=old_entry.origin,
|
||||
display_name=old_entry.display_name,
|
||||
platform=old_entry.platform,
|
||||
chat_type=old_entry.chat_type,
|
||||
)
|
||||
|
||||
self._entries[session_key] = new_entry
|
||||
self._save()
|
||||
|
||||
if self._db and db_end_session_id:
|
||||
try:
|
||||
self._db.end_session(old_entry.session_id, "session_switch")
|
||||
self._db.end_session(db_end_session_id, "session_switch")
|
||||
except Exception as e:
|
||||
logger.debug("Session DB end_session failed: %s", e)
|
||||
|
||||
now = datetime.now()
|
||||
new_entry = SessionEntry(
|
||||
session_key=session_key,
|
||||
session_id=target_session_id,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
origin=old_entry.origin,
|
||||
display_name=old_entry.display_name,
|
||||
platform=old_entry.platform,
|
||||
chat_type=old_entry.chat_type,
|
||||
)
|
||||
|
||||
self._entries[session_key] = new_entry
|
||||
self._save()
|
||||
return new_entry
|
||||
|
||||
def list_sessions(self, active_minutes: Optional[int] = None) -> List[SessionEntry]:
|
||||
"""List all sessions, optionally filtered by activity."""
|
||||
self._ensure_loaded()
|
||||
|
||||
entries = list(self._entries.values())
|
||||
|
||||
with self._lock:
|
||||
self._ensure_loaded_locked()
|
||||
entries = list(self._entries.values())
|
||||
|
||||
if active_minutes is not None:
|
||||
cutoff = datetime.now() - timedelta(minutes=active_minutes)
|
||||
cutoff = _now() - timedelta(minutes=active_minutes)
|
||||
entries = [e for e in entries if e.updated_at >= cutoff]
|
||||
|
||||
|
||||
entries.sort(key=lambda e: e.updated_at, reverse=True)
|
||||
|
||||
|
||||
return entries
|
||||
|
||||
def get_transcript_path(self, session_id: str) -> Path:
|
||||
@@ -891,17 +928,13 @@ class SessionStore:
|
||||
# Write to SQLite (unless the agent already handled it)
|
||||
if self._db and not skip_db:
|
||||
try:
|
||||
_role = message.get("role", "unknown")
|
||||
self._db.append_message(
|
||||
session_id=session_id,
|
||||
role=_role,
|
||||
role=message.get("role", "unknown"),
|
||||
content=message.get("content"),
|
||||
tool_name=message.get("tool_name"),
|
||||
tool_calls=message.get("tool_calls"),
|
||||
tool_call_id=message.get("tool_call_id"),
|
||||
reasoning=message.get("reasoning") if _role == "assistant" else None,
|
||||
reasoning_details=message.get("reasoning_details") if _role == "assistant" else None,
|
||||
codex_reasoning_items=message.get("codex_reasoning_items") if _role == "assistant" else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Session DB operation failed: %s", e)
|
||||
@@ -922,17 +955,13 @@ class SessionStore:
|
||||
try:
|
||||
self._db.clear_messages(session_id)
|
||||
for msg in messages:
|
||||
_role = msg.get("role", "unknown")
|
||||
self._db.append_message(
|
||||
session_id=session_id,
|
||||
role=_role,
|
||||
role=msg.get("role", "unknown"),
|
||||
content=msg.get("content"),
|
||||
tool_name=msg.get("tool_name"),
|
||||
tool_calls=msg.get("tool_calls"),
|
||||
tool_call_id=msg.get("tool_call_id"),
|
||||
reasoning=msg.get("reasoning") if _role == "assistant" else None,
|
||||
reasoning_details=msg.get("reasoning_details") if _role == "assistant" else None,
|
||||
codex_reasoning_items=msg.get("codex_reasoning_items") if _role == "assistant" else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to rewrite transcript in DB: %s", e)
|
||||
|
||||
+2
-1
@@ -17,6 +17,7 @@ import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from hermes_constants import get_hermes_home
|
||||
from typing import Any, Optional
|
||||
|
||||
_GATEWAY_KIND = "hermes-gateway"
|
||||
@@ -26,7 +27,7 @@ _LOCKS_DIRNAME = "gateway-locks"
|
||||
|
||||
def _get_pid_path() -> Path:
|
||||
"""Return the path to the gateway PID file, respecting HERMES_HOME."""
|
||||
home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||
home = get_hermes_home()
|
||||
return home / "gateway.pid"
|
||||
|
||||
|
||||
|
||||
@@ -9,9 +9,7 @@ Cache location: ~/.hermes/sticker_cache.json
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
+3
-3
@@ -2012,7 +2012,7 @@ def _login_openai_codex(args, pconfig: ProviderConfig) -> None:
|
||||
config_path = _update_config_for_provider("openai-codex", creds.get("base_url", DEFAULT_CODEX_BASE_URL))
|
||||
print()
|
||||
print("Login successful!")
|
||||
print(f" Auth state: ~/.hermes/auth.json")
|
||||
print(" Auth state: ~/.hermes/auth.json")
|
||||
print(f" Config updated: {config_path} (model.provider=openai-codex)")
|
||||
|
||||
|
||||
@@ -2056,9 +2056,9 @@ def _codex_device_code_login() -> Dict[str, Any]:
|
||||
|
||||
# Step 2: Show user the code
|
||||
print("To continue, follow these steps:\n")
|
||||
print(f" 1. Open this URL in your browser:")
|
||||
print(" 1. Open this URL in your browser:")
|
||||
print(f" \033[94m{issuer}/codex/device\033[0m\n")
|
||||
print(f" 2. Enter this code:")
|
||||
print(" 2. Enter this code:")
|
||||
print(f" \033[94m{user_code}\033[0m\n")
|
||||
print("Waiting for sign-in... (press Ctrl+C to cancel)")
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Any, Optional
|
||||
from hermes_constants import get_hermes_home
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
@@ -136,7 +137,7 @@ def check_for_updates() -> Optional[int]:
|
||||
``~/.hermes/.update_check``). Returns the number of commits behind,
|
||||
or ``None`` if the check fails or isn't applicable.
|
||||
"""
|
||||
hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||
hermes_home = get_hermes_home()
|
||||
repo_dir = hermes_home / "hermes-agent"
|
||||
cache_file = hermes_home / ".update_check"
|
||||
|
||||
@@ -257,7 +258,7 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
|
||||
get_toolset_for_tool: Callable to map tool name -> toolset name.
|
||||
context_length: Model's context window size in tokens.
|
||||
"""
|
||||
from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS
|
||||
from model_tools import check_tool_availability
|
||||
if get_toolset_for_tool is None:
|
||||
from model_tools import get_toolset_for_tool
|
||||
|
||||
|
||||
+4
-7
@@ -18,10 +18,8 @@ from hermes_cli.setup import (
|
||||
print_header,
|
||||
print_info,
|
||||
print_success,
|
||||
print_warning,
|
||||
print_error,
|
||||
prompt_yes_no,
|
||||
prompt_choice,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -127,7 +125,7 @@ def _cmd_migrate(args):
|
||||
print()
|
||||
print_error(f"OpenClaw directory not found: {source_dir}")
|
||||
print_info("Make sure your OpenClaw installation is at the expected path.")
|
||||
print_info(f"You can specify a custom path: hermes claw migrate --source /path/to/.openclaw")
|
||||
print_info("You can specify a custom path: hermes claw migrate --source /path/to/.openclaw")
|
||||
return
|
||||
|
||||
# Find the migration script
|
||||
@@ -208,7 +206,6 @@ def _print_migration_report(report: dict, dry_run: bool):
|
||||
skipped = summary.get("skipped", 0)
|
||||
conflicts = summary.get("conflict", 0)
|
||||
errors = summary.get("error", 0)
|
||||
total = migrated + skipped + conflicts + errors
|
||||
|
||||
print()
|
||||
if dry_run:
|
||||
@@ -242,7 +239,7 @@ def _print_migration_report(report: dict, dry_run: bool):
|
||||
print()
|
||||
|
||||
if conflict_items:
|
||||
print(color(f" ⚠ Conflicts (skipped — use --overwrite to force):", Colors.YELLOW))
|
||||
print(color(" ⚠ Conflicts (skipped — use --overwrite to force):", Colors.YELLOW))
|
||||
for item in conflict_items:
|
||||
kind = item.get("kind", "unknown")
|
||||
reason = item.get("reason", "already exists")
|
||||
@@ -250,7 +247,7 @@ def _print_migration_report(report: dict, dry_run: bool):
|
||||
print()
|
||||
|
||||
if skipped_items:
|
||||
print(color(f" ─ Skipped:", Colors.DIM))
|
||||
print(color(" ─ Skipped:", Colors.DIM))
|
||||
for item in skipped_items:
|
||||
kind = item.get("kind", "unknown")
|
||||
reason = item.get("reason", "")
|
||||
@@ -258,7 +255,7 @@ def _print_migration_report(report: dict, dry_run: bool):
|
||||
print()
|
||||
|
||||
if error_items:
|
||||
print(color(f" ✗ Errors:", Colors.RED))
|
||||
print(color(" ✗ Errors:", Colors.RED))
|
||||
for item in error_items:
|
||||
kind = item.get("kind", "unknown")
|
||||
reason = item.get("reason", "unknown error")
|
||||
|
||||
+1
-97
@@ -13,8 +13,7 @@ from __future__ import annotations
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from prompt_toolkit.auto_suggest import AutoSuggest, Suggestion
|
||||
@@ -79,8 +78,6 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
# Configuration
|
||||
CommandDef("config", "Show current configuration", "Configuration",
|
||||
cli_only=True),
|
||||
CommandDef("model", "Show or change the current model", "Configuration",
|
||||
args_hint="[name]"),
|
||||
CommandDef("provider", "Show available providers and current provider",
|
||||
"Configuration"),
|
||||
CommandDef("prompt", "View/set custom system prompt", "Configuration",
|
||||
@@ -331,29 +328,8 @@ class SlashCommandCompleter(Completer):
|
||||
def __init__(
|
||||
self,
|
||||
skill_commands_provider: Callable[[], Mapping[str, dict[str, Any]]] | None = None,
|
||||
model_completer_provider: Callable[[], dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
self._skill_commands_provider = skill_commands_provider
|
||||
# model_completer_provider returns {"current_provider": str,
|
||||
# "providers": {id: label, ...}, "models_for": callable(provider) -> list[str]}
|
||||
self._model_completer_provider = model_completer_provider
|
||||
self._model_info_cache: dict[str, Any] | None = None
|
||||
self._model_info_cache_time: float = 0
|
||||
|
||||
def _get_model_info(self) -> dict[str, Any]:
|
||||
"""Get cached model/provider info for /model autocomplete."""
|
||||
import time
|
||||
now = time.monotonic()
|
||||
if self._model_info_cache is not None and now - self._model_info_cache_time < 60:
|
||||
return self._model_info_cache
|
||||
if self._model_completer_provider is None:
|
||||
return {}
|
||||
try:
|
||||
self._model_info_cache = self._model_completer_provider() or {}
|
||||
self._model_info_cache_time = now
|
||||
except Exception:
|
||||
self._model_info_cache = self._model_info_cache or {}
|
||||
return self._model_info_cache
|
||||
|
||||
def _iter_skill_commands(self) -> Mapping[str, dict[str, Any]]:
|
||||
if self._skill_commands_provider is None:
|
||||
@@ -592,52 +568,6 @@ class SlashCommandCompleter(Completer):
|
||||
sub_text = parts[1] if len(parts) > 1 else ""
|
||||
sub_lower = sub_text.lower()
|
||||
|
||||
# /model gets two-stage completion:
|
||||
# Stage 1: provider names (with : suffix)
|
||||
# Stage 2: after "provider:", list that provider's models
|
||||
if base_cmd == "/model" and " " not in sub_text:
|
||||
info = self._get_model_info()
|
||||
if info:
|
||||
current_prov = info.get("current_provider", "")
|
||||
providers = info.get("providers", {})
|
||||
models_for = info.get("models_for")
|
||||
|
||||
if ":" in sub_text:
|
||||
# Stage 2: "anthropic:cl" → models for anthropic
|
||||
prov_part, model_part = sub_text.split(":", 1)
|
||||
model_lower = model_part.lower()
|
||||
if models_for:
|
||||
try:
|
||||
prov_models = models_for(prov_part)
|
||||
except Exception:
|
||||
prov_models = []
|
||||
for mid in prov_models:
|
||||
if mid.lower().startswith(model_lower) and mid.lower() != model_lower:
|
||||
full = f"{prov_part}:{mid}"
|
||||
yield Completion(
|
||||
full,
|
||||
start_position=-len(sub_text),
|
||||
display=mid,
|
||||
)
|
||||
else:
|
||||
# Stage 1: providers sorted: non-current first, current last
|
||||
for pid, plabel in sorted(
|
||||
providers.items(),
|
||||
key=lambda kv: (kv[0] == current_prov, kv[0]),
|
||||
):
|
||||
display_name = f"{pid}:"
|
||||
if display_name.lower().startswith(sub_lower):
|
||||
meta = f"({plabel})" if plabel != pid else ""
|
||||
if pid == current_prov:
|
||||
meta = f"(current — {plabel})" if plabel != pid else "(current)"
|
||||
yield Completion(
|
||||
display_name,
|
||||
start_position=-len(sub_text),
|
||||
display=display_name,
|
||||
display_meta=meta,
|
||||
)
|
||||
return
|
||||
|
||||
# Static subcommand completions
|
||||
if " " not in sub_text and base_cmd in SUBCOMMANDS:
|
||||
for sub in SUBCOMMANDS[base_cmd]:
|
||||
@@ -719,32 +649,6 @@ class SlashCommandAutoSuggest(AutoSuggest):
|
||||
sub_text = parts[1] if len(parts) > 1 else ""
|
||||
sub_lower = sub_text.lower()
|
||||
|
||||
# /model gets two-stage ghost text
|
||||
if base_cmd == "/model" and " " not in sub_text and self._completer:
|
||||
info = self._completer._get_model_info()
|
||||
if info:
|
||||
providers = info.get("providers", {})
|
||||
models_for = info.get("models_for")
|
||||
current_prov = info.get("current_provider", "")
|
||||
|
||||
if ":" in sub_text:
|
||||
# Stage 2: after provider:, suggest model
|
||||
prov_part, model_part = sub_text.split(":", 1)
|
||||
model_lower = model_part.lower()
|
||||
if models_for:
|
||||
try:
|
||||
for mid in models_for(prov_part):
|
||||
if mid.lower().startswith(model_lower) and mid.lower() != model_lower:
|
||||
return Suggestion(mid[len(model_part):])
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# Stage 1: suggest provider name with :
|
||||
for pid in sorted(providers, key=lambda p: (p == current_prov, p)):
|
||||
candidate = f"{pid}:"
|
||||
if candidate.lower().startswith(sub_lower) and candidate.lower() != sub_lower:
|
||||
return Suggestion(candidate[len(sub_text):])
|
||||
|
||||
# Static subcommands
|
||||
if base_cmd in SUBCOMMANDS and SUBCOMMANDS[base_cmd]:
|
||||
if " " not in sub_text:
|
||||
|
||||
+42
-3
@@ -46,13 +46,38 @@ from hermes_cli.colors import Colors, color
|
||||
from hermes_cli.default_soul import DEFAULT_SOUL_MD
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Managed mode (NixOS declarative config)
|
||||
# =============================================================================
|
||||
|
||||
def is_managed() -> bool:
|
||||
"""Check if hermes is running in Nix-managed mode.
|
||||
|
||||
Two signals: the HERMES_MANAGED env var (set by the systemd service),
|
||||
or a .managed marker file in HERMES_HOME (set by the NixOS activation
|
||||
script, so interactive shells also see it).
|
||||
"""
|
||||
if os.getenv("HERMES_MANAGED", "").lower() in ("true", "1", "yes"):
|
||||
return True
|
||||
managed_marker = get_hermes_home() / ".managed"
|
||||
return managed_marker.exists()
|
||||
|
||||
def managed_error(action: str = "modify configuration"):
|
||||
"""Print user-friendly error for managed mode."""
|
||||
print(
|
||||
f"Cannot {action}: configuration is managed by NixOS (HERMES_MANAGED=true).\n"
|
||||
"Edit services.hermes-agent.settings in your configuration.nix and run:\n"
|
||||
" sudo nixos-rebuild switch",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Config paths
|
||||
# =============================================================================
|
||||
|
||||
def get_hermes_home() -> Path:
|
||||
"""Get the Hermes home directory (~/.hermes)."""
|
||||
return Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||
# Re-export from hermes_constants — canonical definition lives there.
|
||||
from hermes_constants import get_hermes_home # noqa: F811,E402
|
||||
|
||||
def get_config_path() -> Path:
|
||||
"""Get the main config file path."""
|
||||
@@ -317,6 +342,8 @@ DEFAULT_CONFIG = {
|
||||
"provider": "", # e.g. "openrouter" (empty = inherit parent provider + credentials)
|
||||
"base_url": "", # direct OpenAI-compatible endpoint for subagents
|
||||
"api_key": "", # API key for delegation.base_url (falls back to OPENAI_API_KEY)
|
||||
"max_iterations": 50, # per-subagent iteration cap (each subagent gets its own budget,
|
||||
# independent of the parent's max_iterations)
|
||||
},
|
||||
|
||||
# Ephemeral prefill messages file — JSON list of {role, content} dicts
|
||||
@@ -1340,6 +1367,9 @@ _COMMENTED_SECTIONS = """
|
||||
|
||||
def save_config(config: Dict[str, Any]):
|
||||
"""Save configuration to ~/.hermes/config.yaml."""
|
||||
if is_managed():
|
||||
managed_error("save configuration")
|
||||
return
|
||||
from utils import atomic_yaml_write
|
||||
|
||||
ensure_hermes_home()
|
||||
@@ -1481,6 +1511,9 @@ def sanitize_env_file() -> int:
|
||||
|
||||
def save_env_value(key: str, value: str):
|
||||
"""Save or update a value in ~/.hermes/.env."""
|
||||
if is_managed():
|
||||
managed_error(f"set {key}")
|
||||
return
|
||||
if not _ENV_VAR_NAME_RE.match(key):
|
||||
raise ValueError(f"Invalid environment variable name: {key!r}")
|
||||
value = value.replace("\n", "").replace("\r", "")
|
||||
@@ -1737,6 +1770,9 @@ def show_config():
|
||||
|
||||
def edit_config():
|
||||
"""Open config file in user's editor."""
|
||||
if is_managed():
|
||||
managed_error("edit configuration")
|
||||
return
|
||||
config_path = get_config_path()
|
||||
|
||||
# Ensure config exists
|
||||
@@ -1766,6 +1802,9 @@ def edit_config():
|
||||
|
||||
def set_config_value(key: str, value: str):
|
||||
"""Set a configuration value."""
|
||||
if is_managed():
|
||||
managed_error("set configuration values")
|
||||
return
|
||||
# Check if it's an API key (goes to .env)
|
||||
api_keys = [
|
||||
'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY',
|
||||
|
||||
@@ -21,12 +21,11 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import os
|
||||
import sys
|
||||
import subprocess
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli.config import get_project_root, get_hermes_home, get_env_path
|
||||
|
||||
@@ -448,7 +447,7 @@ def run_doctor(args):
|
||||
check_fail("DAYTONA_API_KEY not set", "(required for TERMINAL_ENV=daytona)")
|
||||
issues.append("Set DAYTONA_API_KEY environment variable")
|
||||
try:
|
||||
from daytona import Daytona
|
||||
from daytona import Daytona # noqa: F401 — SDK presence check
|
||||
check_ok("daytona SDK", "(installed)")
|
||||
except ImportError:
|
||||
check_fail("daytona SDK not installed", "(pip install daytona)")
|
||||
@@ -706,7 +705,7 @@ def run_doctor(args):
|
||||
_honcho_cfg_path = resolve_config_path()
|
||||
|
||||
if not _honcho_cfg_path.exists():
|
||||
check_warn("Honcho config not found", f"run: hermes honcho setup")
|
||||
check_warn("Honcho config not found", "run: hermes honcho setup")
|
||||
elif not hcfg.enabled:
|
||||
check_info(f"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)")
|
||||
elif not hcfg.api_key:
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
+18
-9
@@ -14,7 +14,7 @@ from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
|
||||
from hermes_cli.config import get_env_value, get_hermes_home, save_env_value
|
||||
from hermes_cli.config import get_env_value, get_hermes_home, save_env_value, is_managed, managed_error
|
||||
from hermes_cli.setup import (
|
||||
print_header, print_info, print_success, print_warning, print_error,
|
||||
prompt, prompt_choice, prompt_yes_no,
|
||||
@@ -134,7 +134,7 @@ def get_service_name() -> str:
|
||||
"""
|
||||
import hashlib
|
||||
from pathlib import Path as _Path # local import to avoid monkeypatch interference
|
||||
home = _Path(os.getenv("HERMES_HOME", _Path.home() / ".hermes")).resolve()
|
||||
home = get_hermes_home().resolve()
|
||||
default = (_Path.home() / ".hermes").resolve()
|
||||
if home == default:
|
||||
return _SERVICE_BASE
|
||||
@@ -437,7 +437,7 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)
|
||||
path_entries.extend(["/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin", "/sbin", "/bin"])
|
||||
sane_path = ":".join(path_entries)
|
||||
|
||||
hermes_home = str(Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")).resolve())
|
||||
hermes_home = str(get_hermes_home().resolve())
|
||||
|
||||
if system:
|
||||
username, group_name, home_dir = _system_service_identity(run_as_user)
|
||||
@@ -1332,9 +1332,9 @@ def _setup_standard_platform(platform: dict):
|
||||
|
||||
# Allowlist fields get special handling for the deny-by-default security model
|
||||
if var.get("is_allowlist"):
|
||||
print_info(f" The gateway DENIES all users by default for security.")
|
||||
print_info(f" Enter user IDs to create an allowlist, or leave empty")
|
||||
print_info(f" and you'll be asked about open access next.")
|
||||
print_info(" The gateway DENIES all users by default for security.")
|
||||
print_info(" Enter user IDs to create an allowlist, or leave empty")
|
||||
print_info(" and you'll be asked about open access next.")
|
||||
value = prompt(f" {var['prompt']}", password=False)
|
||||
if value:
|
||||
cleaned = value.replace(" ", "")
|
||||
@@ -1351,7 +1351,7 @@ def _setup_standard_platform(platform: dict):
|
||||
parts.append(uid)
|
||||
cleaned = ",".join(parts)
|
||||
save_env_value(var["name"], cleaned)
|
||||
print_success(f" Saved — only these users can interact with the bot.")
|
||||
print_success(" Saved — only these users can interact with the bot.")
|
||||
allowed_val_set = cleaned
|
||||
else:
|
||||
# No allowlist — ask about open access vs DM pairing
|
||||
@@ -1380,7 +1380,7 @@ def _setup_standard_platform(platform: dict):
|
||||
print_warning(f" Skipped — {label} won't work without this.")
|
||||
return
|
||||
else:
|
||||
print_info(f" Skipped (can configure later)")
|
||||
print_info(" Skipped (can configure later)")
|
||||
|
||||
# If an allowlist was set and home channel wasn't, offer to reuse
|
||||
# the first user ID (common for Telegram DMs).
|
||||
@@ -1556,12 +1556,15 @@ def _setup_signal():
|
||||
print_success("Signal configured!")
|
||||
print_info(f" URL: {url}")
|
||||
print_info(f" Account: {account}")
|
||||
print_info(f" DM auth: via SIGNAL_ALLOWED_USERS + DM pairing")
|
||||
print_info(" DM auth: via SIGNAL_ALLOWED_USERS + DM pairing")
|
||||
print_info(f" Groups: {'enabled' if get_env_value('SIGNAL_GROUP_ALLOWED_USERS') else 'disabled'}")
|
||||
|
||||
|
||||
def gateway_setup():
|
||||
"""Interactive setup for messaging platforms + gateway service."""
|
||||
if is_managed():
|
||||
managed_error("run gateway setup")
|
||||
return
|
||||
|
||||
print()
|
||||
print(color("┌─────────────────────────────────────────────────────────┐", Colors.MAGENTA))
|
||||
@@ -1716,6 +1719,9 @@ def gateway_command(args):
|
||||
|
||||
# Service management commands
|
||||
if subcmd == "install":
|
||||
if is_managed():
|
||||
managed_error("install gateway service (managed by NixOS)")
|
||||
return
|
||||
force = getattr(args, 'force', False)
|
||||
system = getattr(args, 'system', False)
|
||||
run_as_user = getattr(args, 'run_as_user', None)
|
||||
@@ -1729,6 +1735,9 @@ def gateway_command(args):
|
||||
sys.exit(1)
|
||||
|
||||
elif subcmd == "uninstall":
|
||||
if is_managed():
|
||||
managed_error("uninstall gateway service (managed by NixOS)")
|
||||
return
|
||||
system = getattr(args, 'system', False)
|
||||
if is_linux():
|
||||
systemd_uninstall(system=system)
|
||||
|
||||
+26
-20
@@ -390,7 +390,7 @@ def _session_browse_picker(sessions: list) -> Optional[str]:
|
||||
return sessions[idx]["id"]
|
||||
print(f" Invalid selection. Enter 1-{len(sessions)} or q to cancel.")
|
||||
except ValueError:
|
||||
print(f" Invalid input. Enter a number or q to cancel.")
|
||||
print(" Invalid input. Enter a number or q to cancel.")
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return None
|
||||
@@ -548,7 +548,6 @@ def cmd_gateway(args):
|
||||
|
||||
def cmd_whatsapp(args):
|
||||
"""Set up WhatsApp: choose mode, configure, install bridge, pair via QR."""
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from hermes_cli.config import get_env_value, save_env_value
|
||||
@@ -742,12 +741,9 @@ def cmd_setup(args):
|
||||
def cmd_model(args):
|
||||
"""Select default model — starts with provider selection, then model picker."""
|
||||
from hermes_cli.auth import (
|
||||
resolve_provider, get_provider_auth_state, PROVIDER_REGISTRY,
|
||||
_prompt_model_selection, _save_model_choice, _update_config_for_provider,
|
||||
resolve_nous_runtime_credentials, fetch_nous_models, AuthError, format_auth_error,
|
||||
_login_nous,
|
||||
resolve_provider, AuthError, format_auth_error,
|
||||
)
|
||||
from hermes_cli.config import load_config, save_config, get_env_value, save_env_value
|
||||
from hermes_cli.config import load_config, get_env_value
|
||||
|
||||
config = load_config()
|
||||
current_model = config.get("model")
|
||||
@@ -1983,7 +1979,7 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
|
||||
"""Generic flow for API-key providers (z.ai, MiniMax)."""
|
||||
from hermes_cli.auth import (
|
||||
PROVIDER_REGISTRY, _prompt_model_selection, _save_model_choice,
|
||||
_update_config_for_provider, deactivate_provider,
|
||||
deactivate_provider,
|
||||
)
|
||||
from hermes_cli.config import get_env_value, save_env_value, load_config, save_config
|
||||
|
||||
@@ -2042,8 +2038,8 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
|
||||
else:
|
||||
model_list = _PROVIDER_MODELS.get(provider_id, [])
|
||||
if model_list:
|
||||
print(f" ⚠ Could not auto-detect models from API — showing defaults.")
|
||||
print(f" Use \"Enter custom model name\" if you don't see your model.")
|
||||
print(" ⚠ Could not auto-detect models from API — showing defaults.")
|
||||
print(" Use \"Enter custom model name\" if you don't see your model.")
|
||||
# else: no defaults either, will fall through to raw input
|
||||
|
||||
if model_list:
|
||||
@@ -2167,7 +2163,7 @@ def _model_flow_anthropic(config, current_model=""):
|
||||
import os
|
||||
from hermes_cli.auth import (
|
||||
PROVIDER_REGISTRY, _prompt_model_selection, _save_model_choice,
|
||||
_update_config_for_provider, deactivate_provider,
|
||||
deactivate_provider,
|
||||
)
|
||||
from hermes_cli.config import (
|
||||
get_env_value, save_env_value, load_config, save_config,
|
||||
@@ -2443,8 +2439,9 @@ def _update_via_zip(args):
|
||||
cwd=PROJECT_ROOT, check=True, env=uv_env,
|
||||
)
|
||||
else:
|
||||
venv_pip = PROJECT_ROOT / "venv" / ("Scripts" if sys.platform == "win32" else "bin") / "pip"
|
||||
pip_cmd = [str(venv_pip)] if venv_pip.exists() else ["pip"]
|
||||
# Use sys.executable to explicitly call the venv's pip module,
|
||||
# avoiding PEP 668 'externally-managed-environment' errors on Debian/Ubuntu
|
||||
pip_cmd = [sys.executable, "-m", "pip"]
|
||||
try:
|
||||
subprocess.run(pip_cmd + ["install", "-e", ".[all]", "--quiet"], cwd=PROJECT_ROOT, check=True)
|
||||
except subprocess.CalledProcessError:
|
||||
@@ -2756,8 +2753,9 @@ def cmd_update(args):
|
||||
cwd=PROJECT_ROOT, check=True, env=uv_env,
|
||||
)
|
||||
else:
|
||||
venv_pip = PROJECT_ROOT / "venv" / ("Scripts" if sys.platform == "win32" else "bin") / "pip"
|
||||
pip_cmd = [str(venv_pip)] if venv_pip.exists() else ["pip"]
|
||||
# Use sys.executable to explicitly call the venv's pip module,
|
||||
# avoiding PEP 668 'externally-managed-environment' errors on Debian/Ubuntu
|
||||
pip_cmd = [sys.executable, "-m", "pip"]
|
||||
try:
|
||||
subprocess.run(pip_cmd + ["install", "-e", ".[all]", "--quiet"], cwd=PROJECT_ROOT, check=True)
|
||||
except subprocess.CalledProcessError:
|
||||
@@ -2816,7 +2814,10 @@ def cmd_update(args):
|
||||
print(f" ℹ️ {len(missing_config)} new config option(s) available")
|
||||
|
||||
print()
|
||||
response = input("Would you like to configure them now? [Y/n]: ").strip().lower()
|
||||
if sys.stdin.isatty():
|
||||
response = input("Would you like to configure them now? [Y/n]: ").strip().lower()
|
||||
else:
|
||||
response = "n"
|
||||
|
||||
if response in ('', 'y', 'yes'):
|
||||
print()
|
||||
@@ -3843,6 +3844,13 @@ For more help on a command:
|
||||
sessions_browse.add_argument("--source", help="Filter by source (cli, telegram, discord, etc.)")
|
||||
sessions_browse.add_argument("--limit", type=int, default=50, help="Max sessions to load (default: 50)")
|
||||
|
||||
def _confirm_prompt(prompt: str) -> bool:
|
||||
"""Prompt for y/N confirmation, safe against non-TTY environments."""
|
||||
try:
|
||||
return input(prompt).strip().lower() in ("y", "yes")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return False
|
||||
|
||||
def cmd_sessions(args):
|
||||
import json as _json
|
||||
try:
|
||||
@@ -3903,8 +3911,7 @@ For more help on a command:
|
||||
print(f"Session '{args.session_id}' not found.")
|
||||
return
|
||||
if not args.yes:
|
||||
confirm = input(f"Delete session '{resolved_session_id}' and all its messages? [y/N] ")
|
||||
if confirm.lower() not in ("y", "yes"):
|
||||
if not _confirm_prompt(f"Delete session '{resolved_session_id}' and all its messages? [y/N] "):
|
||||
print("Cancelled.")
|
||||
return
|
||||
if db.delete_session(resolved_session_id):
|
||||
@@ -3916,8 +3923,7 @@ For more help on a command:
|
||||
days = args.older_than
|
||||
source_msg = f" from '{args.source}'" if args.source else ""
|
||||
if not args.yes:
|
||||
confirm = input(f"Delete all ended sessions older than {days} days{source_msg}? [y/N] ")
|
||||
if confirm.lower() not in ("y", "yes"):
|
||||
if not _confirm_prompt(f"Delete all ended sessions older than {days} days{source_msg}? [y/N] "):
|
||||
print("Cancelled.")
|
||||
return
|
||||
count = db.prune_sessions(older_than_days=days, source=args.source)
|
||||
|
||||
@@ -14,15 +14,14 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from hermes_cli.config import (
|
||||
load_config,
|
||||
save_config,
|
||||
get_env_value,
|
||||
save_env_value,
|
||||
get_hermes_home,
|
||||
get_hermes_home, # noqa: F401 — used by test mocks
|
||||
)
|
||||
from hermes_cli.colors import Colors, color
|
||||
|
||||
|
||||
@@ -13,9 +13,7 @@ concerns: state mutation, config persistence, output formatting.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -87,6 +87,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
],
|
||||
"zai": [
|
||||
"glm-5",
|
||||
"glm-5-turbo",
|
||||
"glm-4.7",
|
||||
"glm-4.5",
|
||||
"glm-4.5-flash",
|
||||
|
||||
@@ -72,10 +72,10 @@ def _cmd_approve(store, platform: str, code: str):
|
||||
name = result.get("user_name", "")
|
||||
display = f"{name} ({uid})" if name else uid
|
||||
print(f"\n Approved! User {display} on {platform} can now use the bot~")
|
||||
print(f" They'll be recognized automatically on their next message.\n")
|
||||
print(" They'll be recognized automatically on their next message.\n")
|
||||
else:
|
||||
print(f"\n Code '{code}' not found or expired for platform '{platform}'.")
|
||||
print(f" Run 'hermes pairing list' to see pending codes.\n")
|
||||
print(" Run 'hermes pairing list' to see pending codes.\n")
|
||||
|
||||
|
||||
def _cmd_revoke(store, platform: str, user_id: str):
|
||||
|
||||
@@ -390,7 +390,7 @@ def cmd_list() -> None:
|
||||
dirs = sorted(d for d in plugins_dir.iterdir() if d.is_dir())
|
||||
if not dirs:
|
||||
console.print("[dim]No plugins installed.[/dim]")
|
||||
console.print(f"[dim]Install with:[/dim] hermes plugins install owner/repo")
|
||||
console.print("[dim]Install with:[/dim] hermes plugins install owner/repo")
|
||||
return
|
||||
|
||||
table = Table(title="Installed Plugins", show_lines=False)
|
||||
|
||||
+26
-23
@@ -283,7 +283,6 @@ from hermes_cli.config import (
|
||||
save_env_value,
|
||||
get_env_value,
|
||||
ensure_hermes_home,
|
||||
DEFAULT_CONFIG,
|
||||
)
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
@@ -549,9 +548,9 @@ def _prompt_api_key(var: dict):
|
||||
|
||||
if value:
|
||||
save_env_value(var["name"], value)
|
||||
print_success(f" ✓ Saved")
|
||||
print_success(" ✓ Saved")
|
||||
else:
|
||||
print_warning(f" Skipped (configure later with 'hermes setup')")
|
||||
print_warning(" Skipped (configure later with 'hermes setup')")
|
||||
|
||||
|
||||
def _print_setup_summary(config: dict, hermes_home):
|
||||
@@ -726,9 +725,9 @@ def _print_setup_summary(config: dict, hermes_home):
|
||||
f" {color('hermes config edit', Colors.GREEN)} Open config in your editor"
|
||||
)
|
||||
print(f" {color('hermes config set <key> <value>', Colors.GREEN)}")
|
||||
print(f" Set a specific value")
|
||||
print(" Set a specific value")
|
||||
print()
|
||||
print(f" Or edit the files directly:")
|
||||
print(" Or edit the files directly:")
|
||||
print(f" {color(f'nano {get_config_path()}', Colors.DIM)}")
|
||||
print(f" {color(f'nano {get_env_path()}', Colors.DIM)}")
|
||||
print()
|
||||
@@ -756,13 +755,13 @@ def _prompt_container_resources(config: dict):
|
||||
print_info(" Persistent filesystem keeps files between sessions.")
|
||||
print_info(" Set to 'no' for ephemeral sandboxes that reset each time.")
|
||||
persist_str = prompt(
|
||||
f" Persist filesystem across sessions? (yes/no)", persist_label
|
||||
" Persist filesystem across sessions? (yes/no)", persist_label
|
||||
)
|
||||
terminal["container_persistent"] = persist_str.lower() in ("yes", "true", "y", "1")
|
||||
|
||||
# CPU
|
||||
current_cpu = terminal.get("container_cpu", 1)
|
||||
cpu_str = prompt(f" CPU cores", str(current_cpu))
|
||||
cpu_str = prompt(" CPU cores", str(current_cpu))
|
||||
try:
|
||||
terminal["container_cpu"] = float(cpu_str)
|
||||
except ValueError:
|
||||
@@ -770,7 +769,7 @@ def _prompt_container_resources(config: dict):
|
||||
|
||||
# Memory
|
||||
current_mem = terminal.get("container_memory", 5120)
|
||||
mem_str = prompt(f" Memory in MB (5120 = 5GB)", str(current_mem))
|
||||
mem_str = prompt(" Memory in MB (5120 = 5GB)", str(current_mem))
|
||||
try:
|
||||
terminal["container_memory"] = int(mem_str)
|
||||
except ValueError:
|
||||
@@ -778,7 +777,7 @@ def _prompt_container_resources(config: dict):
|
||||
|
||||
# Disk
|
||||
current_disk = terminal.get("container_disk", 51200)
|
||||
disk_str = prompt(f" Disk in MB (51200 = 50GB)", str(current_disk))
|
||||
disk_str = prompt(" Disk in MB (51200 = 50GB)", str(current_disk))
|
||||
try:
|
||||
terminal["container_disk"] = int(disk_str)
|
||||
except ValueError:
|
||||
@@ -798,15 +797,11 @@ def setup_model_provider(config: dict):
|
||||
"""Configure the inference provider and default model."""
|
||||
from hermes_cli.auth import (
|
||||
get_active_provider,
|
||||
get_provider_auth_state,
|
||||
PROVIDER_REGISTRY,
|
||||
format_auth_error,
|
||||
AuthError,
|
||||
fetch_nous_models,
|
||||
resolve_nous_runtime_credentials,
|
||||
_update_config_for_provider,
|
||||
_login_openai_codex,
|
||||
get_codex_auth_status,
|
||||
resolve_codex_runtime_credentials,
|
||||
DEFAULT_CODEX_BASE_URL,
|
||||
detect_external_credentials,
|
||||
@@ -975,7 +970,7 @@ def setup_model_provider(config: dict):
|
||||
print()
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import _login_nous, ProviderConfig
|
||||
from hermes_cli.auth import _login_nous
|
||||
import argparse
|
||||
|
||||
mock_args = argparse.Namespace(
|
||||
@@ -3106,6 +3101,10 @@ def run_setup_wizard(args):
|
||||
hermes setup tools — just tool configuration
|
||||
hermes setup agent — just agent settings
|
||||
"""
|
||||
from hermes_cli.config import is_managed, managed_error
|
||||
if is_managed():
|
||||
managed_error("run setup wizard")
|
||||
return
|
||||
ensure_hermes_home()
|
||||
|
||||
config = load_config()
|
||||
@@ -3235,12 +3234,17 @@ def run_setup_wizard(args):
|
||||
print_info("Exiting. Run 'hermes setup' again when ready.")
|
||||
return
|
||||
elif 3 <= choice <= 7:
|
||||
# Individual section
|
||||
section_idx = choice - 3
|
||||
_, label, func = SETUP_SECTIONS[section_idx]
|
||||
func(config)
|
||||
save_config(config)
|
||||
_print_setup_summary(config, hermes_home)
|
||||
# Individual section — map by key, not by position.
|
||||
# SETUP_SECTIONS includes TTS but the returning-user menu skips it,
|
||||
# so positional indexing (choice - 3) would dispatch the wrong section.
|
||||
_RETURNING_USER_SECTION_KEYS = ["model", "terminal", "gateway", "tools", "agent"]
|
||||
section_key = _RETURNING_USER_SECTION_KEYS[choice - 3]
|
||||
section = next((s for s in SETUP_SECTIONS if s[0] == section_key), None)
|
||||
if section:
|
||||
_, label, func = section
|
||||
func(config)
|
||||
save_config(config)
|
||||
_print_setup_summary(config, hermes_home)
|
||||
return
|
||||
else:
|
||||
# ── First-Time Setup ──
|
||||
@@ -3299,7 +3303,6 @@ def _run_quick_setup(config: dict, hermes_home):
|
||||
get_missing_env_vars,
|
||||
get_missing_config_fields,
|
||||
check_config_version,
|
||||
migrate_config,
|
||||
)
|
||||
|
||||
print()
|
||||
@@ -3438,9 +3441,9 @@ def _run_quick_setup(config: dict, hermes_home):
|
||||
value = prompt(f" {var.get('prompt', var['name'])}")
|
||||
if value:
|
||||
save_env_value(var["name"], value)
|
||||
print_success(f" ✓ Saved")
|
||||
print_success(" ✓ Saved")
|
||||
else:
|
||||
print_warning(f" Skipped")
|
||||
print_warning(" Skipped")
|
||||
print()
|
||||
|
||||
# Handle missing config fields
|
||||
|
||||
@@ -11,7 +11,7 @@ Config stored in ~/.hermes/config.yaml under:
|
||||
telegram: [skill-c]
|
||||
cli: []
|
||||
"""
|
||||
from typing import Dict, List, Optional, Set
|
||||
from typing import List, Optional, Set
|
||||
|
||||
from hermes_cli.config import load_config, save_config
|
||||
from hermes_cli.colors import Colors, color
|
||||
|
||||
@@ -186,7 +186,7 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
|
||||
Official skills are always shown first, regardless of source filter.
|
||||
"""
|
||||
from tools.skills_hub import (
|
||||
GitHubAuth, create_source_router, OptionalSkillSource, SkillMeta,
|
||||
GitHubAuth, create_source_router,
|
||||
)
|
||||
|
||||
# Clamp page_size to safe range
|
||||
|
||||
@@ -101,6 +101,8 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -513,8 +515,7 @@ _active_skin_name: str = "default"
|
||||
|
||||
def _skins_dir() -> Path:
|
||||
"""User skins directory."""
|
||||
home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||
return home / "skins"
|
||||
return get_hermes_home() / "skins"
|
||||
|
||||
|
||||
def _load_skin_from_yaml(path: Path) -> Optional[Dict[str, Any]]:
|
||||
|
||||
@@ -289,7 +289,7 @@ def show_status(args):
|
||||
)
|
||||
is_active = result.stdout.strip() == "active"
|
||||
print(f" Status: {check_mark(is_active)} {'running' if is_active else 'stopped'}")
|
||||
print(f" Manager: systemd (user)")
|
||||
print(" Manager: systemd (user)")
|
||||
|
||||
elif sys.platform == 'darwin':
|
||||
result = subprocess.run(
|
||||
@@ -299,10 +299,10 @@ def show_status(args):
|
||||
)
|
||||
is_loaded = result.returncode == 0
|
||||
print(f" Status: {check_mark(is_loaded)} {'loaded' if is_loaded else 'not loaded'}")
|
||||
print(f" Manager: launchd")
|
||||
print(" Manager: launchd")
|
||||
else:
|
||||
print(f" Status: {color('N/A', Colors.DIM)}")
|
||||
print(f" Manager: (not supported on this platform)")
|
||||
print(" Manager: (not supported on this platform)")
|
||||
|
||||
# =========================================================================
|
||||
# Cron Jobs
|
||||
@@ -320,9 +320,9 @@ def show_status(args):
|
||||
enabled_jobs = [j for j in jobs if j.get("enabled", True)]
|
||||
print(f" Jobs: {len(enabled_jobs)} active, {len(jobs)} total")
|
||||
except Exception:
|
||||
print(f" Jobs: (error reading jobs file)")
|
||||
print(" Jobs: (error reading jobs file)")
|
||||
else:
|
||||
print(f" Jobs: 0")
|
||||
print(" Jobs: 0")
|
||||
|
||||
# =========================================================================
|
||||
# Sessions
|
||||
@@ -338,9 +338,9 @@ def show_status(args):
|
||||
data = json.load(f)
|
||||
print(f" Active: {len(data)} session(s)")
|
||||
except Exception:
|
||||
print(f" Active: (error reading sessions file)")
|
||||
print(" Active: (error reading sessions file)")
|
||||
else:
|
||||
print(f" Active: 0")
|
||||
print(" Active: 0")
|
||||
|
||||
# =========================================================================
|
||||
# Deep checks
|
||||
|
||||
+11
-13
@@ -13,11 +13,9 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
import os
|
||||
|
||||
from hermes_cli.config import (
|
||||
load_config, save_config, get_env_value, save_env_value,
|
||||
get_hermes_home,
|
||||
)
|
||||
from hermes_cli.colors import Colors, color
|
||||
|
||||
@@ -382,7 +380,7 @@ def _platform_toolset_summary(config: dict, platforms: Optional[List[str]] = Non
|
||||
|
||||
def _get_platform_tools(config: dict, platform: str) -> Set[str]:
|
||||
"""Resolve which individual toolset names are enabled for a platform."""
|
||||
from toolsets import resolve_toolset, TOOLSETS
|
||||
from toolsets import resolve_toolset
|
||||
|
||||
platform_toolsets = config.get("platform_toolsets", {})
|
||||
toolset_names = platform_toolsets.get(platform)
|
||||
@@ -661,7 +659,7 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict):
|
||||
# Multiple providers - let user choose
|
||||
print()
|
||||
# Use custom title if provided (e.g. "Select Search Provider")
|
||||
title = cat.get("setup_title", f"Choose a provider")
|
||||
title = cat.get("setup_title", "Choose a provider")
|
||||
print(color(f" --- {icon} {name} - {title} ---", Colors.CYAN))
|
||||
if cat.get("setup_note"):
|
||||
_print_info(f" {cat['setup_note']}")
|
||||
@@ -770,9 +768,9 @@ def _configure_provider(provider: dict, config: dict):
|
||||
|
||||
if value:
|
||||
save_env_value(var["key"], value)
|
||||
_print_success(f" Saved")
|
||||
_print_success(" Saved")
|
||||
else:
|
||||
_print_warning(f" Skipped")
|
||||
_print_warning(" Skipped")
|
||||
all_configured = False
|
||||
|
||||
# Run post-setup hooks if needed
|
||||
@@ -836,9 +834,9 @@ def _configure_simple_requirements(ts_key: str):
|
||||
value = _prompt(f" {var}", password=True)
|
||||
if value and value.strip():
|
||||
save_env_value(var, value.strip())
|
||||
_print_success(f" Saved")
|
||||
_print_success(" Saved")
|
||||
else:
|
||||
_print_warning(f" Skipped")
|
||||
_print_warning(" Skipped")
|
||||
|
||||
|
||||
def _reconfigure_tool(config: dict):
|
||||
@@ -926,7 +924,7 @@ def _reconfigure_provider(provider: dict, config: dict):
|
||||
_print_success(f" Browser cloud provider set to: {bp}")
|
||||
else:
|
||||
config.get("browser", {}).pop("cloud_provider", None)
|
||||
_print_success(f" Browser set to local mode")
|
||||
_print_success(" Browser set to local mode")
|
||||
|
||||
# Set web search backend in config if applicable
|
||||
if provider.get("web_backend"):
|
||||
@@ -948,9 +946,9 @@ def _reconfigure_provider(provider: dict, config: dict):
|
||||
value = _prompt(f" {var.get('prompt', var['key'])} (Enter to keep current)", password=not default_val)
|
||||
if value and value.strip():
|
||||
save_env_value(var["key"], value.strip())
|
||||
_print_success(f" Updated")
|
||||
_print_success(" Updated")
|
||||
else:
|
||||
_print_info(f" Kept current")
|
||||
_print_info(" Kept current")
|
||||
|
||||
|
||||
def _reconfigure_simple_requirements(ts_key: str):
|
||||
@@ -972,9 +970,9 @@ def _reconfigure_simple_requirements(ts_key: str):
|
||||
value = _prompt(f" {var} (Enter to keep current)", password=True)
|
||||
if value and value.strip():
|
||||
save_env_value(var, value.strip())
|
||||
_print_success(f" Updated")
|
||||
_print_success(" Updated")
|
||||
else:
|
||||
_print_info(f" Kept current")
|
||||
_print_info(" Kept current")
|
||||
|
||||
|
||||
# ─── Main Entry Point ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -7,11 +7,11 @@ Provides options for:
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
|
||||
@@ -33,11 +33,6 @@ def get_project_root() -> Path:
|
||||
return Path(__file__).parent.parent.resolve()
|
||||
|
||||
|
||||
def get_hermes_home() -> Path:
|
||||
"""Get the Hermes home directory (~/.hermes)."""
|
||||
return Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||
|
||||
|
||||
def find_shell_configs() -> list:
|
||||
"""Find shell configuration files that might have PATH entries."""
|
||||
home = Path.home()
|
||||
@@ -278,7 +273,7 @@ def run_uninstall(args):
|
||||
log_info("No wrapper script found")
|
||||
|
||||
# 4. Remove installation directory (code)
|
||||
log_info(f"Removing installation directory...")
|
||||
log_info("Removing installation directory...")
|
||||
|
||||
# Check if we're running from within the install dir
|
||||
# We need to be careful here
|
||||
|
||||
@@ -4,6 +4,40 @@ Import-safe module with no dependencies — can be imported from anywhere
|
||||
without risk of circular imports.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_hermes_home() -> Path:
|
||||
"""Return the Hermes home directory (default: ~/.hermes).
|
||||
|
||||
Reads HERMES_HOME env var, falls back to ~/.hermes.
|
||||
This is the single source of truth — all other copies should import this.
|
||||
"""
|
||||
return Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||
|
||||
|
||||
VALID_REASONING_EFFORTS = ("xhigh", "high", "medium", "low", "minimal")
|
||||
|
||||
|
||||
def parse_reasoning_effort(effort: str) -> dict | None:
|
||||
"""Parse a reasoning effort level into a config dict.
|
||||
|
||||
Valid levels: "xhigh", "high", "medium", "low", "minimal", "none".
|
||||
Returns None when the input is empty or unrecognized (caller uses default).
|
||||
Returns {"enabled": False} for "none".
|
||||
Returns {"enabled": True, "effort": <level>} for valid effort levels.
|
||||
"""
|
||||
if not effort or not effort.strip():
|
||||
return None
|
||||
effort = effort.strip().lower()
|
||||
if effort == "none":
|
||||
return {"enabled": False}
|
||||
if effort in VALID_REASONING_EFFORTS:
|
||||
return {"enabled": True, "effort": effort}
|
||||
return None
|
||||
|
||||
|
||||
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||||
OPENROUTER_MODELS_URL = f"{OPENROUTER_BASE_URL}/models"
|
||||
OPENROUTER_CHAT_URL = f"{OPENROUTER_BASE_URL}/chat/completions"
|
||||
|
||||
+2
-1
@@ -21,10 +21,11 @@ import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from hermes_constants import get_hermes_home
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
|
||||
DEFAULT_DB_PATH = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) / "state.db"
|
||||
DEFAULT_DB_PATH = get_hermes_home() / "state.db"
|
||||
|
||||
SCHEMA_VERSION = 6
|
||||
|
||||
|
||||
+3
-2
@@ -15,8 +15,9 @@ crashes due to a bad timezone string.
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone as _tz
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from hermes_constants import get_hermes_home
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -48,7 +49,7 @@ def _resolve_timezone_name() -> str:
|
||||
# 2. config.yaml ``timezone`` key
|
||||
try:
|
||||
import yaml
|
||||
hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||
hermes_home = get_hermes_home()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
if config_path.exists():
|
||||
with open(config_path) as f:
|
||||
|
||||
+34
-34
@@ -141,7 +141,7 @@ def cmd_setup(args) -> None:
|
||||
|
||||
# Memory mode
|
||||
current_mode = hermes_host.get("memoryMode") or cfg.get("memoryMode", "hybrid")
|
||||
print(f"\n Memory mode options:")
|
||||
print("\n Memory mode options:")
|
||||
print(" hybrid — write to both Honcho and local MEMORY.md (default)")
|
||||
print(" honcho — Honcho only, skip MEMORY.md writes")
|
||||
new_mode = _prompt("Memory mode", default=current_mode)
|
||||
@@ -152,7 +152,7 @@ def cmd_setup(args) -> None:
|
||||
|
||||
# Write frequency
|
||||
current_wf = str(hermes_host.get("writeFrequency") or cfg.get("writeFrequency", "async"))
|
||||
print(f"\n Write frequency options:")
|
||||
print("\n Write frequency options:")
|
||||
print(" async — background thread, no token cost (recommended)")
|
||||
print(" turn — sync write after every turn")
|
||||
print(" session — batch write at session end only")
|
||||
@@ -166,7 +166,7 @@ def cmd_setup(args) -> None:
|
||||
# Recall mode
|
||||
_raw_recall = hermes_host.get("recallMode") or cfg.get("recallMode", "hybrid")
|
||||
current_recall = "hybrid" if _raw_recall not in ("hybrid", "context", "tools") else _raw_recall
|
||||
print(f"\n Recall mode options:")
|
||||
print("\n Recall mode options:")
|
||||
print(" hybrid — auto-injected context + Honcho tools available (default)")
|
||||
print(" context — auto-injected context only, Honcho tools hidden")
|
||||
print(" tools — Honcho tools only, no auto-injected context")
|
||||
@@ -176,7 +176,7 @@ def cmd_setup(args) -> None:
|
||||
|
||||
# Session strategy
|
||||
current_strat = hermes_host.get("sessionStrategy") or cfg.get("sessionStrategy", "per-directory")
|
||||
print(f"\n Session strategy options:")
|
||||
print("\n Session strategy options:")
|
||||
print(" per-directory — one session per working directory (default)")
|
||||
print(" per-session — new Honcho session each run, named by Hermes session ID")
|
||||
print(" per-repo — one session per git repository (uses repo root name)")
|
||||
@@ -203,7 +203,7 @@ def cmd_setup(args) -> None:
|
||||
print(f"FAILED\n Error: {e}")
|
||||
return
|
||||
|
||||
print(f"\n Honcho is ready.")
|
||||
print("\n Honcho is ready.")
|
||||
print(f" Session: {hcfg.resolve_session_name()}")
|
||||
print(f" Workspace: {hcfg.workspace_id}")
|
||||
print(f" Peer: {hcfg.peer_name}")
|
||||
@@ -213,17 +213,17 @@ def cmd_setup(args) -> None:
|
||||
_mode_str = f"{hcfg.memory_mode} (peers: {overrides})"
|
||||
print(f" Mode: {_mode_str}")
|
||||
print(f" Frequency: {hcfg.write_frequency}")
|
||||
print(f"\n Honcho tools available in chat:")
|
||||
print(f" honcho_context — ask Honcho a question about you (LLM-synthesized)")
|
||||
print(f" honcho_search — semantic search over your history (no LLM)")
|
||||
print(f" honcho_profile — your peer card, key facts (no LLM)")
|
||||
print(f" honcho_conclude — persist a user fact to Honcho memory (no LLM)")
|
||||
print(f"\n Other commands:")
|
||||
print(f" hermes honcho status — show full config")
|
||||
print(f" hermes honcho mode — show or change memory mode")
|
||||
print(f" hermes honcho tokens — show or set token budgets")
|
||||
print(f" hermes honcho identity — seed or show AI peer identity")
|
||||
print(f" hermes honcho map <name> — map this directory to a session name\n")
|
||||
print("\n Honcho tools available in chat:")
|
||||
print(" honcho_context — ask Honcho a question about you (LLM-synthesized)")
|
||||
print(" honcho_search — semantic search over your history (no LLM)")
|
||||
print(" honcho_profile — your peer card, key facts (no LLM)")
|
||||
print(" honcho_conclude — persist a user fact to Honcho memory (no LLM)")
|
||||
print("\n Other commands:")
|
||||
print(" hermes honcho status — show full config")
|
||||
print(" hermes honcho mode — show or change memory mode")
|
||||
print(" hermes honcho tokens — show or set token budgets")
|
||||
print(" hermes honcho identity — seed or show AI peer identity")
|
||||
print(" hermes honcho map <name> — map this directory to a session name\n")
|
||||
|
||||
|
||||
def cmd_status(args) -> None:
|
||||
@@ -253,7 +253,7 @@ def cmd_status(args) -> None:
|
||||
api_key = hcfg.api_key or ""
|
||||
masked = f"...{api_key[-8:]}" if len(api_key) > 8 else ("set" if api_key else "not set")
|
||||
|
||||
print(f"\nHoncho status\n" + "─" * 40)
|
||||
print("\nHoncho status\n" + "─" * 40)
|
||||
print(f" Enabled: {hcfg.enabled}")
|
||||
print(f" API key: {masked}")
|
||||
print(f" Workspace: {hcfg.workspace_id}")
|
||||
@@ -265,7 +265,7 @@ def cmd_status(args) -> None:
|
||||
print(f" Recall mode: {hcfg.recall_mode}")
|
||||
print(f" Memory mode: {hcfg.memory_mode}")
|
||||
if hcfg.peer_memory_modes:
|
||||
print(f" Per-peer modes:")
|
||||
print(" Per-peer modes:")
|
||||
for peer, mode in hcfg.peer_memory_modes.items():
|
||||
print(f" {peer}: {mode}")
|
||||
print(f" Write freq: {hcfg.write_frequency}")
|
||||
@@ -345,12 +345,12 @@ def cmd_peer(args) -> None:
|
||||
ai = hermes.get('aiPeer') or cfg.get('aiPeer') or HOST
|
||||
lvl = hermes.get("dialecticReasoningLevel") or cfg.get("dialecticReasoningLevel") or "low"
|
||||
max_chars = hermes.get("dialecticMaxChars") or cfg.get("dialecticMaxChars") or 600
|
||||
print(f"\nHoncho peers\n" + "─" * 40)
|
||||
print("\nHoncho peers\n" + "─" * 40)
|
||||
print(f" User peer: {user}")
|
||||
print(f" Your identity in Honcho. Messages you send build this peer's card.")
|
||||
print(" Your identity in Honcho. Messages you send build this peer's card.")
|
||||
print(f" AI peer: {ai}")
|
||||
print(f" Hermes' identity in Honcho. Seed with 'hermes honcho identity <file>'.")
|
||||
print(f" Dialectic calls ask this peer questions to warm session context.")
|
||||
print(" Hermes' identity in Honcho. Seed with 'hermes honcho identity <file>'.")
|
||||
print(" Dialectic calls ask this peer questions to warm session context.")
|
||||
print()
|
||||
print(f" Dialectic reasoning: {lvl} ({', '.join(REASONING_LEVELS)})")
|
||||
print(f" Dialectic cap: {max_chars} chars\n")
|
||||
@@ -394,11 +394,11 @@ def cmd_mode(args) -> None:
|
||||
or cfg.get("memoryMode")
|
||||
or "hybrid"
|
||||
)
|
||||
print(f"\nHoncho memory mode\n" + "─" * 40)
|
||||
print("\nHoncho memory mode\n" + "─" * 40)
|
||||
for m, desc in MODES.items():
|
||||
marker = " ←" if m == current else ""
|
||||
print(f" {m:<8} {desc}{marker}")
|
||||
print(f"\n Set with: hermes honcho mode [hybrid|honcho]\n")
|
||||
print("\n Set with: hermes honcho mode [hybrid|honcho]\n")
|
||||
return
|
||||
|
||||
if mode_arg not in MODES:
|
||||
@@ -423,18 +423,18 @@ def cmd_tokens(args) -> None:
|
||||
ctx_tokens = hermes.get("contextTokens") or cfg.get("contextTokens") or "(Honcho default)"
|
||||
d_chars = hermes.get("dialecticMaxChars") or cfg.get("dialecticMaxChars") or 600
|
||||
d_level = hermes.get("dialecticReasoningLevel") or cfg.get("dialecticReasoningLevel") or "low"
|
||||
print(f"\nHoncho budgets\n" + "─" * 40)
|
||||
print("\nHoncho budgets\n" + "─" * 40)
|
||||
print()
|
||||
print(f" Context {ctx_tokens} tokens")
|
||||
print(f" Raw memory retrieval. Honcho returns stored facts/history about")
|
||||
print(f" the user and session, injected directly into the system prompt.")
|
||||
print(" Raw memory retrieval. Honcho returns stored facts/history about")
|
||||
print(" the user and session, injected directly into the system prompt.")
|
||||
print()
|
||||
print(f" Dialectic {d_chars} chars, reasoning: {d_level}")
|
||||
print(f" AI-to-AI inference. Hermes asks Honcho's AI peer a question")
|
||||
print(f" (e.g. \"what were we working on?\") and Honcho runs its own model")
|
||||
print(f" to synthesize an answer. Used for first-turn session continuity.")
|
||||
print(f" Level controls how much reasoning Honcho spends on the answer.")
|
||||
print(f"\n Set with: hermes honcho tokens [--context N] [--dialectic N]\n")
|
||||
print(" AI-to-AI inference. Hermes asks Honcho's AI peer a question")
|
||||
print(" (e.g. \"what were we working on?\") and Honcho runs its own model")
|
||||
print(" to synthesize an answer. Used for first-turn session continuity.")
|
||||
print(" Level controls how much reasoning Honcho spends on the answer.")
|
||||
print("\n Set with: hermes honcho tokens [--context N] [--dialectic N]\n")
|
||||
return
|
||||
|
||||
changed = False
|
||||
@@ -523,7 +523,7 @@ def cmd_identity(args) -> None:
|
||||
print(f" Seeded AI peer identity from {p.name} into session '{session_key}'")
|
||||
print(f" Honcho will incorporate this into {hcfg.ai_peer}'s representation over time.\n")
|
||||
else:
|
||||
print(f" Failed to seed identity. Check logs for details.\n")
|
||||
print(" Failed to seed identity. Check logs for details.\n")
|
||||
|
||||
|
||||
def cmd_migrate(args) -> None:
|
||||
@@ -623,7 +623,7 @@ def cmd_migrate(args) -> None:
|
||||
print()
|
||||
print(" If you want to migrate them now without starting a session:")
|
||||
for f in user_files:
|
||||
print(f" hermes honcho migrate — this step handles it interactively")
|
||||
print(" hermes honcho migrate — this step handles it interactively")
|
||||
if has_key:
|
||||
answer = _prompt(" Upload user memory files to Honcho now?", default="y")
|
||||
if answer.lower() in ("y", "yes"):
|
||||
|
||||
@@ -18,6 +18,8 @@ import os
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -29,11 +31,6 @@ GLOBAL_CONFIG_PATH = Path.home() / ".honcho" / "config.json"
|
||||
HOST = "hermes"
|
||||
|
||||
|
||||
def _get_hermes_home() -> Path:
|
||||
"""Get HERMES_HOME without importing hermes_cli (avoids circular deps)."""
|
||||
return Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||
|
||||
|
||||
def resolve_config_path() -> Path:
|
||||
"""Return the active Honcho config path.
|
||||
|
||||
@@ -41,7 +38,7 @@ def resolve_config_path() -> Path:
|
||||
to ~/.honcho/config.json (global). Returns the global path if neither
|
||||
exists (for first-time setup writes).
|
||||
"""
|
||||
local_path = _get_hermes_home() / "honcho.json"
|
||||
local_path = get_hermes_home() / "honcho.json"
|
||||
if local_path.exists():
|
||||
return local_path
|
||||
return GLOBAL_CONFIG_PATH
|
||||
|
||||
+5
-5
@@ -217,7 +217,7 @@ class MiniSWERunner:
|
||||
# Tool definition
|
||||
self.tools = [TERMINAL_TOOL_DEFINITION]
|
||||
|
||||
print(f"🤖 Mini-SWE Runner initialized")
|
||||
print("🤖 Mini-SWE Runner initialized")
|
||||
print(f" Model: {self.model}")
|
||||
print(f" Environment: {self.env_type}")
|
||||
if self.env_type != "local":
|
||||
@@ -233,7 +233,7 @@ class MiniSWERunner:
|
||||
cwd=self.cwd,
|
||||
timeout=self.command_timeout
|
||||
)
|
||||
print(f"✅ Environment ready")
|
||||
print("✅ Environment ready")
|
||||
|
||||
def _cleanup_env(self):
|
||||
"""Cleanup the execution environment."""
|
||||
@@ -365,7 +365,7 @@ class MiniSWERunner:
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
|
||||
tool_response = f"<tool_response>\n"
|
||||
tool_response = "<tool_response>\n"
|
||||
tool_response += json.dumps({
|
||||
"tool_call_id": tool_msg.get("tool_call_id", ""),
|
||||
"name": msg["tool_calls"][len(tool_responses)]["function"]["name"] \
|
||||
@@ -505,7 +505,7 @@ Complete the user's task step by step."""
|
||||
|
||||
# Check for task completion signal
|
||||
if "MINI_SWE_AGENT_FINAL_OUTPUT" in result["output"]:
|
||||
print(f" ✅ Task completion signal detected!")
|
||||
print(" ✅ Task completion signal detected!")
|
||||
completed = True
|
||||
|
||||
# Add tool response
|
||||
@@ -530,7 +530,7 @@ Complete the user's task step by step."""
|
||||
"content": final_response
|
||||
})
|
||||
completed = True
|
||||
print(f"🎉 Agent finished (no more tool calls)")
|
||||
print("🎉 Agent finished (no more tool calls)")
|
||||
break
|
||||
|
||||
if api_call_count >= self.max_iterations:
|
||||
|
||||
+343
@@ -0,0 +1,343 @@
|
||||
# nix/checks.nix — Build-time verification tests
|
||||
#
|
||||
# Checks are Linux-only: the full Python venv (via uv2nix) includes
|
||||
# transitive deps like onnxruntime that lack compatible wheels on
|
||||
# aarch64-darwin. The package and devShell still work on macOS.
|
||||
{ inputs, ... }: {
|
||||
perSystem = { pkgs, system, lib, ... }:
|
||||
let
|
||||
hermes-agent = inputs.self.packages.${system}.default;
|
||||
hermesVenv = pkgs.callPackage ./python.nix {
|
||||
inherit (inputs) uv2nix pyproject-nix pyproject-build-systems;
|
||||
};
|
||||
|
||||
configMergeScript = pkgs.callPackage ./configMergeScript.nix { };
|
||||
|
||||
# Auto-generated config key reference — always in sync with Python
|
||||
configKeys = pkgs.runCommand "hermes-config-keys" {} ''
|
||||
set -euo pipefail
|
||||
export HOME=$TMPDIR
|
||||
${hermesVenv}/bin/python3 -c '
|
||||
import json, sys
|
||||
from hermes_cli.config import DEFAULT_CONFIG
|
||||
|
||||
def leaf_paths(d, prefix=""):
|
||||
paths = []
|
||||
for k, v in sorted(d.items()):
|
||||
path = f"{prefix}.{k}" if prefix else k
|
||||
if isinstance(v, dict) and v:
|
||||
paths.extend(leaf_paths(v, path))
|
||||
else:
|
||||
paths.append(path)
|
||||
return paths
|
||||
|
||||
json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
|
||||
' > $out
|
||||
'';
|
||||
in {
|
||||
packages.configKeys = configKeys;
|
||||
|
||||
checks = lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux {
|
||||
# Verify binaries exist and are executable
|
||||
package-contents = pkgs.runCommand "hermes-package-contents" { } ''
|
||||
set -e
|
||||
echo "=== Checking binaries ==="
|
||||
test -x ${hermes-agent}/bin/hermes || (echo "FAIL: hermes binary missing"; exit 1)
|
||||
test -x ${hermes-agent}/bin/hermes-agent || (echo "FAIL: hermes-agent binary missing"; exit 1)
|
||||
echo "PASS: All binaries present"
|
||||
|
||||
echo "=== Checking version ==="
|
||||
${hermes-agent}/bin/hermes version 2>&1 | grep -qi "hermes" || (echo "FAIL: version check"; exit 1)
|
||||
echo "PASS: Version check"
|
||||
|
||||
echo "=== All checks passed ==="
|
||||
mkdir -p $out
|
||||
echo "ok" > $out/result
|
||||
'';
|
||||
|
||||
# Verify every pyproject.toml [project.scripts] entry has a wrapped binary
|
||||
entry-points-sync = pkgs.runCommand "hermes-entry-points-sync" { } ''
|
||||
set -e
|
||||
echo "=== Checking entry points match pyproject.toml [project.scripts] ==="
|
||||
for bin in hermes hermes-agent hermes-acp; do
|
||||
test -x ${hermes-agent}/bin/$bin || (echo "FAIL: $bin binary missing from Nix package"; exit 1)
|
||||
echo "PASS: $bin present"
|
||||
done
|
||||
|
||||
mkdir -p $out
|
||||
echo "ok" > $out/result
|
||||
'';
|
||||
|
||||
# Verify CLI subcommands are accessible
|
||||
cli-commands = pkgs.runCommand "hermes-cli-commands" { } ''
|
||||
set -e
|
||||
export HOME=$(mktemp -d)
|
||||
|
||||
echo "=== Checking hermes --help ==="
|
||||
${hermes-agent}/bin/hermes --help 2>&1 | grep -q "gateway" || (echo "FAIL: gateway subcommand missing"; exit 1)
|
||||
${hermes-agent}/bin/hermes --help 2>&1 | grep -q "config" || (echo "FAIL: config subcommand missing"; exit 1)
|
||||
echo "PASS: All subcommands accessible"
|
||||
|
||||
echo "=== All CLI checks passed ==="
|
||||
mkdir -p $out
|
||||
echo "ok" > $out/result
|
||||
'';
|
||||
|
||||
# Verify bundled skills are present in the package
|
||||
bundled-skills = pkgs.runCommand "hermes-bundled-skills" { } ''
|
||||
set -e
|
||||
echo "=== Checking bundled skills ==="
|
||||
test -d ${hermes-agent}/share/hermes-agent/skills || (echo "FAIL: skills directory missing"; exit 1)
|
||||
echo "PASS: skills directory exists"
|
||||
|
||||
SKILL_COUNT=$(find ${hermes-agent}/share/hermes-agent/skills -name "SKILL.md" | wc -l)
|
||||
test "$SKILL_COUNT" -gt 0 || (echo "FAIL: no SKILL.md files found in skills directory"; exit 1)
|
||||
echo "PASS: $SKILL_COUNT bundled skills found"
|
||||
|
||||
grep -q "HERMES_BUNDLED_SKILLS" ${hermes-agent}/bin/hermes || \
|
||||
(echo "FAIL: HERMES_BUNDLED_SKILLS not in wrapper"; exit 1)
|
||||
echo "PASS: HERMES_BUNDLED_SKILLS set in wrapper"
|
||||
|
||||
echo "=== All bundled skills checks passed ==="
|
||||
mkdir -p $out
|
||||
echo "ok" > $out/result
|
||||
'';
|
||||
|
||||
# Verify HERMES_MANAGED guard works on all mutation commands
|
||||
managed-guard = pkgs.runCommand "hermes-managed-guard" { } ''
|
||||
set -e
|
||||
export HOME=$(mktemp -d)
|
||||
|
||||
check_blocked() {
|
||||
local label="$1"
|
||||
shift
|
||||
OUTPUT=$(HERMES_MANAGED=true "$@" 2>&1 || true)
|
||||
echo "$OUTPUT" | grep -q "managed by NixOS" || (echo "FAIL: $label not guarded"; echo "$OUTPUT"; exit 1)
|
||||
echo "PASS: $label blocked in managed mode"
|
||||
}
|
||||
|
||||
echo "=== Checking HERMES_MANAGED guards ==="
|
||||
check_blocked "config set" ${hermes-agent}/bin/hermes config set model foo
|
||||
check_blocked "config edit" ${hermes-agent}/bin/hermes config edit
|
||||
|
||||
echo "=== All guard checks passed ==="
|
||||
mkdir -p $out
|
||||
echo "ok" > $out/result
|
||||
'';
|
||||
|
||||
# ── Config merge + round-trip test ────────────────────────────────
|
||||
# Tests the merge script (Nix activation behavior) across 7
|
||||
# scenarios, then verifies Python's load_config() reads correctly.
|
||||
config-roundtrip = let
|
||||
# Nix settings used across scenarios
|
||||
nixSettings = pkgs.writeText "nix-settings.json" (builtins.toJSON {
|
||||
model = "test/nix-model";
|
||||
toolsets = ["nix-toolset"];
|
||||
terminal = { backend = "docker"; timeout = 999; };
|
||||
mcp_servers = {
|
||||
nix-server = { command = "echo"; args = ["nix"]; };
|
||||
};
|
||||
});
|
||||
|
||||
# Pre-built YAML fixtures for each scenario
|
||||
fixtureB = pkgs.writeText "fixture-b.yaml" ''
|
||||
model: "old-model"
|
||||
mcp_servers:
|
||||
old-server:
|
||||
url: "http://old"
|
||||
'';
|
||||
fixtureC = pkgs.writeText "fixture-c.yaml" ''
|
||||
skills:
|
||||
disabled:
|
||||
- skill-a
|
||||
- skill-b
|
||||
session_reset:
|
||||
mode: idle
|
||||
idle_minutes: 30
|
||||
streaming:
|
||||
enabled: true
|
||||
fallback_model:
|
||||
provider: openrouter
|
||||
model: test-fallback
|
||||
'';
|
||||
fixtureD = pkgs.writeText "fixture-d.yaml" ''
|
||||
model: "user-model"
|
||||
skills:
|
||||
disabled:
|
||||
- skill-x
|
||||
streaming:
|
||||
enabled: true
|
||||
transport: edit
|
||||
'';
|
||||
fixtureE = pkgs.writeText "fixture-e.yaml" ''
|
||||
mcp_servers:
|
||||
user-server:
|
||||
url: "http://user-mcp"
|
||||
nix-server:
|
||||
command: "old-cmd"
|
||||
args: ["old"]
|
||||
'';
|
||||
fixtureF = pkgs.writeText "fixture-f.yaml" ''
|
||||
terminal:
|
||||
cwd: "/user/path"
|
||||
custom_key: "preserved"
|
||||
env_passthrough:
|
||||
- USER_VAR
|
||||
'';
|
||||
|
||||
in pkgs.runCommand "hermes-config-roundtrip" {
|
||||
nativeBuildInputs = [ pkgs.jq ];
|
||||
} ''
|
||||
set -e
|
||||
export HOME=$(mktemp -d)
|
||||
ERRORS=""
|
||||
|
||||
fail() { ERRORS="$ERRORS\nFAIL: $1"; }
|
||||
|
||||
# Helper: run merge then load with Python, output merged JSON
|
||||
merge_and_load() {
|
||||
local hermes_home="$1"
|
||||
export HERMES_HOME="$hermes_home"
|
||||
${configMergeScript} ${nixSettings} "$hermes_home/config.yaml"
|
||||
${hermesVenv}/bin/python3 -c '
|
||||
import json, sys
|
||||
from hermes_cli.config import load_config
|
||||
json.dump(load_config(), sys.stdout, default=str)
|
||||
'
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Scenario A: Fresh install — no existing config.yaml
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
echo "=== Scenario A: Fresh install ==="
|
||||
A_HOME=$(mktemp -d)
|
||||
A_CONFIG=$(merge_and_load "$A_HOME")
|
||||
|
||||
echo "$A_CONFIG" | jq -e '.model == "test/nix-model"' > /dev/null \
|
||||
|| fail "A: model not set from Nix"
|
||||
echo "$A_CONFIG" | jq -e '.mcp_servers."nix-server".command == "echo"' > /dev/null \
|
||||
|| fail "A: MCP nix-server missing"
|
||||
echo "PASS: Scenario A"
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Scenario B: Nix keys override existing values
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
echo "=== Scenario B: Nix overrides ==="
|
||||
B_HOME=$(mktemp -d)
|
||||
install -m 0644 ${fixtureB} "$B_HOME/config.yaml"
|
||||
B_CONFIG=$(merge_and_load "$B_HOME")
|
||||
|
||||
echo "$B_CONFIG" | jq -e '.model == "test/nix-model"' > /dev/null \
|
||||
|| fail "B: Nix model did not override"
|
||||
echo "PASS: Scenario B"
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Scenario C: User-only keys preserved
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
echo "=== Scenario C: User keys preserved ==="
|
||||
C_HOME=$(mktemp -d)
|
||||
install -m 0644 ${fixtureC} "$C_HOME/config.yaml"
|
||||
C_CONFIG=$(merge_and_load "$C_HOME")
|
||||
|
||||
echo "$C_CONFIG" | jq -e '.skills.disabled == ["skill-a", "skill-b"]' > /dev/null \
|
||||
|| fail "C: skills.disabled not preserved"
|
||||
echo "$C_CONFIG" | jq -e '.session_reset.mode == "idle"' > /dev/null \
|
||||
|| fail "C: session_reset.mode not preserved"
|
||||
echo "$C_CONFIG" | jq -e '.session_reset.idle_minutes == 30' > /dev/null \
|
||||
|| fail "C: session_reset.idle_minutes not preserved"
|
||||
echo "$C_CONFIG" | jq -e '.streaming.enabled == true' > /dev/null \
|
||||
|| fail "C: streaming.enabled not preserved"
|
||||
echo "$C_CONFIG" | jq -e '.fallback_model.provider == "openrouter"' > /dev/null \
|
||||
|| fail "C: fallback_model not preserved"
|
||||
echo "PASS: Scenario C"
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Scenario D: Mixed — Nix wins for its keys, user keys preserved
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
echo "=== Scenario D: Mixed merge ==="
|
||||
D_HOME=$(mktemp -d)
|
||||
install -m 0644 ${fixtureD} "$D_HOME/config.yaml"
|
||||
D_CONFIG=$(merge_and_load "$D_HOME")
|
||||
|
||||
echo "$D_CONFIG" | jq -e '.model == "test/nix-model"' > /dev/null \
|
||||
|| fail "D: Nix model did not override user model"
|
||||
echo "$D_CONFIG" | jq -e '.skills.disabled == ["skill-x"]' > /dev/null \
|
||||
|| fail "D: user skills not preserved"
|
||||
echo "$D_CONFIG" | jq -e '.streaming.enabled == true' > /dev/null \
|
||||
|| fail "D: user streaming not preserved"
|
||||
echo "PASS: Scenario D"
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Scenario E: MCP additive merge
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
echo "=== Scenario E: MCP additive merge ==="
|
||||
E_HOME=$(mktemp -d)
|
||||
install -m 0644 ${fixtureE} "$E_HOME/config.yaml"
|
||||
E_CONFIG=$(merge_and_load "$E_HOME")
|
||||
|
||||
echo "$E_CONFIG" | jq -e '.mcp_servers."user-server".url == "http://user-mcp"' > /dev/null \
|
||||
|| fail "E: user MCP server not preserved"
|
||||
echo "$E_CONFIG" | jq -e '.mcp_servers."nix-server".command == "echo"' > /dev/null \
|
||||
|| fail "E: Nix MCP server did not override same-name user server"
|
||||
echo "$E_CONFIG" | jq -e '.mcp_servers."nix-server".args == ["nix"]' > /dev/null \
|
||||
|| fail "E: Nix MCP server args wrong"
|
||||
echo "PASS: Scenario E"
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Scenario F: Nested deep merge
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
echo "=== Scenario F: Nested deep merge ==="
|
||||
F_HOME=$(mktemp -d)
|
||||
install -m 0644 ${fixtureF} "$F_HOME/config.yaml"
|
||||
F_CONFIG=$(merge_and_load "$F_HOME")
|
||||
|
||||
echo "$F_CONFIG" | jq -e '.terminal.backend == "docker"' > /dev/null \
|
||||
|| fail "F: Nix terminal.backend did not override"
|
||||
echo "$F_CONFIG" | jq -e '.terminal.timeout == 999' > /dev/null \
|
||||
|| fail "F: Nix terminal.timeout did not override"
|
||||
echo "$F_CONFIG" | jq -e '.terminal.custom_key == "preserved"' > /dev/null \
|
||||
|| fail "F: terminal.custom_key not preserved"
|
||||
echo "$F_CONFIG" | jq -e '.terminal.cwd == "/user/path"' > /dev/null \
|
||||
|| fail "F: user terminal.cwd not preserved when Nix does not set it"
|
||||
echo "$F_CONFIG" | jq -e '.terminal.env_passthrough == ["USER_VAR"]' > /dev/null \
|
||||
|| fail "F: user terminal.env_passthrough not preserved"
|
||||
echo "PASS: Scenario F"
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Scenario G: Idempotency — merging twice yields the same result
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
echo "=== Scenario G: Idempotency ==="
|
||||
G_HOME=$(mktemp -d)
|
||||
install -m 0644 ${fixtureD} "$G_HOME/config.yaml"
|
||||
${configMergeScript} ${nixSettings} "$G_HOME/config.yaml"
|
||||
FIRST=$(cat "$G_HOME/config.yaml")
|
||||
${configMergeScript} ${nixSettings} "$G_HOME/config.yaml"
|
||||
SECOND=$(cat "$G_HOME/config.yaml")
|
||||
|
||||
if [ "$FIRST" != "$SECOND" ]; then
|
||||
fail "G: second merge produced different output"
|
||||
echo "--- first ---"
|
||||
echo "$FIRST"
|
||||
echo "--- second ---"
|
||||
echo "$SECOND"
|
||||
fi
|
||||
echo "PASS: Scenario G"
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Report
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
if [ -n "$ERRORS" ]; then
|
||||
echo ""
|
||||
echo "FAILURES:"
|
||||
echo -e "$ERRORS"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== All 7 merge scenarios passed ==="
|
||||
mkdir -p $out
|
||||
echo "ok" > $out/result
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
# nix/configMergeScript.nix — Deep-merge Nix settings into existing config.yaml
|
||||
#
|
||||
# Used by the NixOS module activation script and by checks.nix tests.
|
||||
# Nix keys override; user-added keys (skills, streaming, etc.) are preserved.
|
||||
{ pkgs }:
|
||||
pkgs.writeScript "hermes-config-merge" ''
|
||||
#!${pkgs.python3.withPackages (ps: [ ps.pyyaml ])}/bin/python3
|
||||
import json, yaml, sys
|
||||
from pathlib import Path
|
||||
|
||||
nix_json, config_path = sys.argv[1], Path(sys.argv[2])
|
||||
|
||||
with open(nix_json) as f:
|
||||
nix = json.load(f)
|
||||
|
||||
existing = {}
|
||||
if config_path.exists():
|
||||
with open(config_path) as f:
|
||||
existing = yaml.safe_load(f) or {}
|
||||
|
||||
def deep_merge(base, override):
|
||||
result = dict(base)
|
||||
for k, v in override.items():
|
||||
if k in result and isinstance(result[k], dict) and isinstance(v, dict):
|
||||
result[k] = deep_merge(result[k], v)
|
||||
else:
|
||||
result[k] = v
|
||||
return result
|
||||
|
||||
merged = deep_merge(existing, nix)
|
||||
with open(config_path, "w") as f:
|
||||
yaml.dump(merged, f, default_flow_style=False, sort_keys=False)
|
||||
''
|
||||
@@ -0,0 +1,51 @@
|
||||
# nix/devShell.nix — Fast dev shell with stamp-file optimization
|
||||
{ inputs, ... }: {
|
||||
perSystem = { pkgs, ... }:
|
||||
let
|
||||
python = pkgs.python311;
|
||||
in {
|
||||
devShells.default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
python uv nodejs_20 ripgrep git openssh ffmpeg
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
echo "Hermes Agent dev shell"
|
||||
|
||||
# Composite stamp: changes when nix python or uv change
|
||||
STAMP_VALUE="${python}:${pkgs.uv}"
|
||||
STAMP_FILE=".venv/.nix-stamp"
|
||||
|
||||
# Create venv if missing
|
||||
if [ ! -d .venv ]; then
|
||||
echo "Creating Python 3.11 venv..."
|
||||
uv venv .venv --python ${python}/bin/python3
|
||||
fi
|
||||
|
||||
source .venv/bin/activate
|
||||
|
||||
# Only install if stamp is stale or missing
|
||||
if [ ! -f "$STAMP_FILE" ] || [ "$(cat "$STAMP_FILE")" != "$STAMP_VALUE" ]; then
|
||||
echo "Installing Python dependencies..."
|
||||
uv pip install -e ".[all]"
|
||||
if [ -d mini-swe-agent ]; then
|
||||
uv pip install -e ./mini-swe-agent 2>/dev/null || true
|
||||
fi
|
||||
if [ -d tinker-atropos ]; then
|
||||
uv pip install -e ./tinker-atropos 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Install npm deps
|
||||
if [ -f package.json ] && [ ! -d node_modules ]; then
|
||||
echo "Installing npm dependencies..."
|
||||
npm install
|
||||
fi
|
||||
|
||||
echo "$STAMP_VALUE" > "$STAMP_FILE"
|
||||
fi
|
||||
|
||||
echo "Ready. Run 'hermes' to start."
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,716 @@
|
||||
# nix/nixosModules.nix — NixOS module for hermes-agent
|
||||
#
|
||||
# Two modes:
|
||||
# container.enable = false (default) → native systemd service
|
||||
# container.enable = true → OCI container (persistent writable layer)
|
||||
#
|
||||
# Container mode: hermes runs from /nix/store bind-mounted read-only into a
|
||||
# plain Ubuntu container. The writable layer (apt/pip/npm installs) persists
|
||||
# across restarts and agent updates. Only image/volume/options changes trigger
|
||||
# container recreation. Environment variables are written to $HERMES_HOME/.env
|
||||
# and read by hermes at startup — no container recreation needed for env changes.
|
||||
#
|
||||
# Usage:
|
||||
# services.hermes-agent = {
|
||||
# enable = true;
|
||||
# settings.model = "anthropic/claude-sonnet-4";
|
||||
# environmentFiles = [ config.sops.secrets."hermes/env".path ];
|
||||
# };
|
||||
#
|
||||
{ inputs, ... }: {
|
||||
flake.nixosModules.default = { config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
cfg = config.services.hermes-agent;
|
||||
hermes-agent = inputs.self.packages.${pkgs.system}.default;
|
||||
|
||||
# Deep-merge config type (from 0xrsydn/nix-hermes-agent)
|
||||
deepConfigType = lib.types.mkOptionType {
|
||||
name = "hermes-config-attrs";
|
||||
description = "Hermes YAML config (attrset), merged deeply via lib.recursiveUpdate.";
|
||||
check = builtins.isAttrs;
|
||||
merge = _loc: defs: lib.foldl' lib.recursiveUpdate { } (map (d: d.value) defs);
|
||||
};
|
||||
|
||||
# Generate config.yaml from Nix attrset (YAML is a superset of JSON)
|
||||
configJson = builtins.toJSON cfg.settings;
|
||||
generatedConfigFile = pkgs.writeText "hermes-config.yaml" configJson;
|
||||
configFile = if cfg.configFile != null then cfg.configFile else generatedConfigFile;
|
||||
|
||||
configMergeScript = pkgs.callPackage ./configMergeScript.nix { };
|
||||
|
||||
# Generate .env from non-secret environment attrset
|
||||
envFileContent = lib.concatStringsSep "\n" (
|
||||
lib.mapAttrsToList (k: v: "${k}=${v}") cfg.environment
|
||||
);
|
||||
# Build documents derivation (from 0xrsydn)
|
||||
documentDerivation = pkgs.runCommand "hermes-documents" { } (
|
||||
''
|
||||
mkdir -p $out
|
||||
'' + lib.concatStringsSep "\n" (
|
||||
lib.mapAttrsToList (name: value:
|
||||
if builtins.isPath value || lib.isStorePath value
|
||||
then "cp ${value} $out/${name}"
|
||||
else "cat > $out/${name} <<'HERMES_DOC_EOF'\n${value}\nHERMES_DOC_EOF"
|
||||
) cfg.documents
|
||||
)
|
||||
);
|
||||
|
||||
containerName = "hermes-agent";
|
||||
containerDataDir = "/data"; # stateDir mount point inside container
|
||||
containerHomeDir = "/home/hermes";
|
||||
|
||||
# ── Container mode helpers ──────────────────────────────────────────
|
||||
containerBin = if cfg.container.backend == "docker"
|
||||
then "${pkgs.docker}/bin/docker"
|
||||
else "${pkgs.podman}/bin/podman";
|
||||
|
||||
# Runs as root inside the container on every start. Provisions the
|
||||
# hermes user + sudo on first boot (writable layer persists), then
|
||||
# drops privileges. Supports arbitrary base images (Debian, Alpine, etc).
|
||||
containerEntrypoint = pkgs.writeShellScript "hermes-container-entrypoint" ''
|
||||
set -eu
|
||||
|
||||
HERMES_UID="''${HERMES_UID:?HERMES_UID must be set}"
|
||||
HERMES_GID="''${HERMES_GID:?HERMES_GID must be set}"
|
||||
|
||||
# ── Group: ensure a group with GID=$HERMES_GID exists ──
|
||||
# Check by GID (not name) to avoid collisions with pre-existing groups
|
||||
# (e.g. GID 100 = "users" on Ubuntu)
|
||||
EXISTING_GROUP=$(getent group "$HERMES_GID" 2>/dev/null | cut -d: -f1 || true)
|
||||
if [ -n "$EXISTING_GROUP" ]; then
|
||||
GROUP_NAME="$EXISTING_GROUP"
|
||||
else
|
||||
GROUP_NAME="hermes"
|
||||
if command -v groupadd >/dev/null 2>&1; then
|
||||
groupadd -g "$HERMES_GID" "$GROUP_NAME"
|
||||
elif command -v addgroup >/dev/null 2>&1; then
|
||||
addgroup -g "$HERMES_GID" "$GROUP_NAME" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── User: ensure a user with UID=$HERMES_UID exists ──
|
||||
PASSWD_ENTRY=$(getent passwd "$HERMES_UID" 2>/dev/null || true)
|
||||
if [ -n "$PASSWD_ENTRY" ]; then
|
||||
TARGET_USER=$(echo "$PASSWD_ENTRY" | cut -d: -f1)
|
||||
TARGET_HOME=$(echo "$PASSWD_ENTRY" | cut -d: -f6)
|
||||
else
|
||||
TARGET_USER="hermes"
|
||||
TARGET_HOME="/home/hermes"
|
||||
if command -v useradd >/dev/null 2>&1; then
|
||||
useradd -u "$HERMES_UID" -g "$HERMES_GID" -m -d "$TARGET_HOME" -s /bin/bash "$TARGET_USER"
|
||||
elif command -v adduser >/dev/null 2>&1; then
|
||||
adduser -u "$HERMES_UID" -D -h "$TARGET_HOME" -s /bin/sh -G "$GROUP_NAME" "$TARGET_USER" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
mkdir -p "$TARGET_HOME"
|
||||
chown "$HERMES_UID:$HERMES_GID" "$TARGET_HOME"
|
||||
|
||||
# Ensure HERMES_HOME is owned by the target user
|
||||
if [ -n "''${HERMES_HOME:-}" ] && [ -d "$HERMES_HOME" ]; then
|
||||
chown -R "$HERMES_UID:$HERMES_GID" "$HERMES_HOME"
|
||||
fi
|
||||
|
||||
# Install sudo on Debian/Ubuntu if missing (first boot only, cached in writable layer)
|
||||
if command -v apt-get >/dev/null 2>&1 && ! command -v sudo >/dev/null 2>&1; then
|
||||
apt-get update -qq >/dev/null 2>&1 && apt-get install -y -qq sudo >/dev/null 2>&1 || true
|
||||
fi
|
||||
if command -v sudo >/dev/null 2>&1 && [ ! -f /etc/sudoers.d/hermes ]; then
|
||||
mkdir -p /etc/sudoers.d
|
||||
echo "$TARGET_USER ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/hermes
|
||||
chmod 0440 /etc/sudoers.d/hermes
|
||||
fi
|
||||
|
||||
if command -v setpriv >/dev/null 2>&1; then
|
||||
exec setpriv --reuid="$HERMES_UID" --regid="$HERMES_GID" --init-groups "$@"
|
||||
elif command -v su >/dev/null 2>&1; then
|
||||
exec su -s /bin/sh "$TARGET_USER" -c 'exec "$0" "$@"' -- "$@"
|
||||
else
|
||||
echo "WARNING: no privilege-drop tool (setpriv/su), running as root" >&2
|
||||
exec "$@"
|
||||
fi
|
||||
'';
|
||||
|
||||
# Identity hash — only recreate container when structural config changes.
|
||||
# Package and entrypoint use stable symlinks (current-package, current-entrypoint)
|
||||
# so they can update without recreation. Env vars go through $HERMES_HOME/.env.
|
||||
containerIdentity = builtins.hashString "sha256" (builtins.toJSON {
|
||||
schema = 3; # bump when identity inputs change
|
||||
image = cfg.container.image;
|
||||
extraVolumes = cfg.container.extraVolumes;
|
||||
extraOptions = cfg.container.extraOptions;
|
||||
});
|
||||
|
||||
identityFile = "${cfg.stateDir}/.container-identity";
|
||||
|
||||
# Default: /var/lib/hermes/workspace → /data/workspace.
|
||||
# Custom paths outside stateDir pass through unchanged (user must add extraVolumes).
|
||||
containerWorkDir =
|
||||
if lib.hasPrefix "${cfg.stateDir}/" cfg.workingDirectory
|
||||
then "${containerDataDir}/${lib.removePrefix "${cfg.stateDir}/" cfg.workingDirectory}"
|
||||
else cfg.workingDirectory;
|
||||
|
||||
in {
|
||||
options.services.hermes-agent = with lib; {
|
||||
enable = mkEnableOption "Hermes Agent gateway service";
|
||||
|
||||
# ── Package ──────────────────────────────────────────────────────────
|
||||
package = mkOption {
|
||||
type = types.package;
|
||||
default = hermes-agent;
|
||||
description = "The hermes-agent package to use.";
|
||||
};
|
||||
|
||||
# ── Service identity ─────────────────────────────────────────────────
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
default = "hermes";
|
||||
description = "System user running the gateway.";
|
||||
};
|
||||
|
||||
group = mkOption {
|
||||
type = types.str;
|
||||
default = "hermes";
|
||||
description = "System group running the gateway.";
|
||||
};
|
||||
|
||||
createUser = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = "Create the user/group automatically.";
|
||||
};
|
||||
|
||||
# ── Directories ──────────────────────────────────────────────────────
|
||||
stateDir = mkOption {
|
||||
type = types.str;
|
||||
default = "/var/lib/hermes";
|
||||
description = "State directory. Contains .hermes/ subdir (HERMES_HOME).";
|
||||
};
|
||||
|
||||
workingDirectory = mkOption {
|
||||
type = types.str;
|
||||
default = "${cfg.stateDir}/workspace";
|
||||
defaultText = literalExpression ''"''${cfg.stateDir}/workspace"'';
|
||||
description = "Working directory for the agent (MESSAGING_CWD).";
|
||||
};
|
||||
|
||||
# ── Declarative config ───────────────────────────────────────────────
|
||||
configFile = mkOption {
|
||||
type = types.nullOr types.path;
|
||||
default = null;
|
||||
description = ''
|
||||
Path to an existing config.yaml. If set, takes precedence over
|
||||
the declarative `settings` option.
|
||||
'';
|
||||
};
|
||||
|
||||
settings = mkOption {
|
||||
type = deepConfigType;
|
||||
default = { };
|
||||
description = ''
|
||||
Declarative Hermes config (attrset). Deep-merged across module
|
||||
definitions and rendered as config.yaml.
|
||||
'';
|
||||
example = literalExpression ''
|
||||
{
|
||||
model = "anthropic/claude-sonnet-4";
|
||||
terminal.backend = "local";
|
||||
compression = { enabled = true; threshold = 0.85; };
|
||||
toolsets = [ "all" ];
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
# ── Secrets / environment ────────────────────────────────────────────
|
||||
environmentFiles = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
description = ''
|
||||
Paths to environment files containing secrets (API keys, tokens).
|
||||
Contents are merged into $HERMES_HOME/.env at activation time.
|
||||
Hermes reads this file on every startup via load_hermes_dotenv().
|
||||
'';
|
||||
};
|
||||
|
||||
environment = mkOption {
|
||||
type = types.attrsOf types.str;
|
||||
default = { };
|
||||
description = ''
|
||||
Non-secret environment variables. Merged into $HERMES_HOME/.env
|
||||
at activation time. Do NOT put secrets here — use environmentFiles.
|
||||
'';
|
||||
};
|
||||
|
||||
authFile = mkOption {
|
||||
type = types.nullOr types.path;
|
||||
default = null;
|
||||
description = ''
|
||||
Path to an auth.json seed file (OAuth credentials).
|
||||
Only copied on first deploy — existing auth.json is preserved.
|
||||
'';
|
||||
};
|
||||
|
||||
authFileForceOverwrite = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Always overwrite auth.json from authFile on activation.";
|
||||
};
|
||||
|
||||
# ── Documents ────────────────────────────────────────────────────────
|
||||
documents = mkOption {
|
||||
type = types.attrsOf (types.either types.str types.path);
|
||||
default = { };
|
||||
description = ''
|
||||
Workspace files (SOUL.md, USER.md, etc.). Keys are filenames,
|
||||
values are inline strings or paths. Installed into workingDirectory.
|
||||
'';
|
||||
example = literalExpression ''
|
||||
{
|
||||
"SOUL.md" = "You are a helpful AI assistant.";
|
||||
"USER.md" = ./documents/USER.md;
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
# ── MCP Servers ──────────────────────────────────────────────────────
|
||||
mcpServers = mkOption {
|
||||
type = types.attrsOf (types.submodule {
|
||||
options = {
|
||||
# Stdio transport
|
||||
command = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
description = "MCP server command (stdio transport).";
|
||||
};
|
||||
args = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
description = "Command-line arguments (stdio transport).";
|
||||
};
|
||||
env = mkOption {
|
||||
type = types.attrsOf types.str;
|
||||
default = { };
|
||||
description = "Environment variables for the server process (stdio transport).";
|
||||
};
|
||||
|
||||
# HTTP/StreamableHTTP transport
|
||||
url = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
description = "MCP server endpoint URL (HTTP/StreamableHTTP transport).";
|
||||
};
|
||||
headers = mkOption {
|
||||
type = types.attrsOf types.str;
|
||||
default = { };
|
||||
description = "HTTP headers, e.g. for authentication (HTTP transport).";
|
||||
};
|
||||
|
||||
# Authentication
|
||||
auth = mkOption {
|
||||
type = types.nullOr (types.enum [ "oauth" ]);
|
||||
default = null;
|
||||
description = ''
|
||||
Authentication method. Set to "oauth" for OAuth 2.1 PKCE flow
|
||||
(remote MCP servers). Tokens are stored in $HERMES_HOME/mcp-tokens/.
|
||||
'';
|
||||
};
|
||||
|
||||
# Enable/disable
|
||||
enabled = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = "Enable or disable this MCP server.";
|
||||
};
|
||||
|
||||
# Common options
|
||||
timeout = mkOption {
|
||||
type = types.nullOr types.int;
|
||||
default = null;
|
||||
description = "Tool call timeout in seconds (default: 120).";
|
||||
};
|
||||
connect_timeout = mkOption {
|
||||
type = types.nullOr types.int;
|
||||
default = null;
|
||||
description = "Initial connection timeout in seconds (default: 60).";
|
||||
};
|
||||
|
||||
# Tool filtering
|
||||
tools = mkOption {
|
||||
type = types.nullOr (types.submodule {
|
||||
options = {
|
||||
include = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
description = "Tool allowlist — only these tools are registered.";
|
||||
};
|
||||
exclude = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
description = "Tool blocklist — these tools are hidden.";
|
||||
};
|
||||
};
|
||||
});
|
||||
default = null;
|
||||
description = "Filter which tools are exposed by this server.";
|
||||
};
|
||||
|
||||
# Sampling (server-initiated LLM requests)
|
||||
sampling = mkOption {
|
||||
type = types.nullOr (types.submodule {
|
||||
options = {
|
||||
enabled = mkOption { type = types.bool; default = true; description = "Enable sampling."; };
|
||||
model = mkOption { type = types.nullOr types.str; default = null; description = "Override model for sampling requests."; };
|
||||
max_tokens_cap = mkOption { type = types.nullOr types.int; default = null; description = "Max tokens per request."; };
|
||||
timeout = mkOption { type = types.nullOr types.int; default = null; description = "LLM call timeout in seconds."; };
|
||||
max_rpm = mkOption { type = types.nullOr types.int; default = null; description = "Max requests per minute."; };
|
||||
max_tool_rounds = mkOption { type = types.nullOr types.int; default = null; description = "Max tool-use rounds per sampling request."; };
|
||||
allowed_models = mkOption { type = types.listOf types.str; default = [ ]; description = "Models the server is allowed to request."; };
|
||||
log_level = mkOption {
|
||||
type = types.nullOr (types.enum [ "debug" "info" "warning" ]);
|
||||
default = null;
|
||||
description = "Audit log level for sampling requests.";
|
||||
};
|
||||
};
|
||||
});
|
||||
default = null;
|
||||
description = "Sampling configuration for server-initiated LLM requests.";
|
||||
};
|
||||
};
|
||||
});
|
||||
default = { };
|
||||
description = ''
|
||||
MCP server configurations (merged into settings.mcp_servers).
|
||||
Each server uses either stdio (command/args) or HTTP (url) transport.
|
||||
'';
|
||||
example = literalExpression ''
|
||||
{
|
||||
filesystem = {
|
||||
command = "npx";
|
||||
args = [ "-y" "@modelcontextprotocol/server-filesystem" "/home/user" ];
|
||||
};
|
||||
remote-api = {
|
||||
url = "http://my-server:8080/v0/mcp";
|
||||
headers = { Authorization = "Bearer ..."; };
|
||||
};
|
||||
remote-oauth = {
|
||||
url = "https://mcp.example.com/mcp";
|
||||
auth = "oauth";
|
||||
};
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
# ── Service behavior ─────────────────────────────────────────────────
|
||||
extraArgs = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
description = "Extra command-line arguments for `hermes gateway`.";
|
||||
};
|
||||
|
||||
extraPackages = mkOption {
|
||||
type = types.listOf types.package;
|
||||
default = [ ];
|
||||
description = "Extra packages available on PATH.";
|
||||
};
|
||||
|
||||
restart = mkOption {
|
||||
type = types.str;
|
||||
default = "always";
|
||||
description = "systemd Restart= policy.";
|
||||
};
|
||||
|
||||
restartSec = mkOption {
|
||||
type = types.int;
|
||||
default = 5;
|
||||
description = "systemd RestartSec= value.";
|
||||
};
|
||||
|
||||
addToSystemPackages = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Add hermes CLI to environment.systemPackages.";
|
||||
};
|
||||
|
||||
# ── OCI Container (opt-in) ──────────────────────────────────────────
|
||||
container = {
|
||||
enable = mkEnableOption "OCI container mode (Ubuntu base, full self-modification support)";
|
||||
|
||||
backend = mkOption {
|
||||
type = types.enum [ "docker" "podman" ];
|
||||
default = "docker";
|
||||
description = "Container runtime.";
|
||||
};
|
||||
|
||||
extraVolumes = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
description = "Extra volume mounts (host:container:mode format).";
|
||||
example = [ "/home/user/projects:/projects:rw" ];
|
||||
};
|
||||
|
||||
extraOptions = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
description = "Extra arguments passed to docker/podman run.";
|
||||
};
|
||||
|
||||
image = mkOption {
|
||||
type = types.str;
|
||||
default = "ubuntu:24.04";
|
||||
description = "OCI container image. The container pulls this at runtime via Docker/Podman.";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable (lib.mkMerge [
|
||||
|
||||
# ── Merge MCP servers into settings ────────────────────────────────
|
||||
(lib.mkIf (cfg.mcpServers != { }) {
|
||||
services.hermes-agent.settings.mcp_servers = lib.mapAttrs (_name: srv:
|
||||
# Stdio transport
|
||||
lib.optionalAttrs (srv.command != null) { inherit (srv) command args; }
|
||||
// lib.optionalAttrs (srv.env != { }) { inherit (srv) env; }
|
||||
# HTTP transport
|
||||
// lib.optionalAttrs (srv.url != null) { inherit (srv) url; }
|
||||
// lib.optionalAttrs (srv.headers != { }) { inherit (srv) headers; }
|
||||
# Auth
|
||||
// lib.optionalAttrs (srv.auth != null) { inherit (srv) auth; }
|
||||
# Enable/disable
|
||||
// { inherit (srv) enabled; }
|
||||
# Common options
|
||||
// lib.optionalAttrs (srv.timeout != null) { inherit (srv) timeout; }
|
||||
// lib.optionalAttrs (srv.connect_timeout != null) { inherit (srv) connect_timeout; }
|
||||
# Tool filtering
|
||||
// lib.optionalAttrs (srv.tools != null) {
|
||||
tools = lib.filterAttrs (_: v: v != [ ]) {
|
||||
inherit (srv.tools) include exclude;
|
||||
};
|
||||
}
|
||||
# Sampling
|
||||
// lib.optionalAttrs (srv.sampling != null) {
|
||||
sampling = lib.filterAttrs (_: v: v != null && v != [ ]) {
|
||||
inherit (srv.sampling) enabled model max_tokens_cap timeout max_rpm
|
||||
max_tool_rounds allowed_models log_level;
|
||||
};
|
||||
}
|
||||
) cfg.mcpServers;
|
||||
})
|
||||
|
||||
# ── User / group ──────────────────────────────────────────────────
|
||||
(lib.mkIf cfg.createUser {
|
||||
users.groups.${cfg.group} = { };
|
||||
users.users.${cfg.user} = {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
home = cfg.stateDir;
|
||||
createHome = true;
|
||||
shell = pkgs.bashInteractive;
|
||||
};
|
||||
})
|
||||
|
||||
# ── Host CLI ──────────────────────────────────────────────────────
|
||||
(lib.mkIf cfg.addToSystemPackages {
|
||||
environment.systemPackages = [ cfg.package ];
|
||||
})
|
||||
|
||||
# ── Directories ───────────────────────────────────────────────────
|
||||
{
|
||||
systemd.tmpfiles.rules = [
|
||||
"d ${cfg.stateDir} 0755 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.stateDir}/.hermes 0755 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.stateDir}/home 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.workingDirectory} 0750 ${cfg.user} ${cfg.group} - -"
|
||||
];
|
||||
}
|
||||
|
||||
# ── Activation: link config + auth + documents ────────────────────
|
||||
{
|
||||
system.activationScripts."hermes-agent-setup" = lib.stringAfter [ "users" ] ''
|
||||
# Ensure directories exist (activation runs before tmpfiles)
|
||||
mkdir -p ${cfg.stateDir}/.hermes
|
||||
mkdir -p ${cfg.stateDir}/home
|
||||
mkdir -p ${cfg.workingDirectory}
|
||||
chown ${cfg.user}:${cfg.group} ${cfg.stateDir} ${cfg.stateDir}/.hermes ${cfg.stateDir}/home ${cfg.workingDirectory}
|
||||
|
||||
# Merge Nix settings into existing config.yaml.
|
||||
# Preserves user-added keys (skills, streaming, etc.); Nix keys win.
|
||||
# If configFile is user-provided (not generated), overwrite instead of merge.
|
||||
${if cfg.configFile != null then ''
|
||||
install -o ${cfg.user} -g ${cfg.group} -m 0644 -D ${configFile} ${cfg.stateDir}/.hermes/config.yaml
|
||||
'' else ''
|
||||
${configMergeScript} ${generatedConfigFile} ${cfg.stateDir}/.hermes/config.yaml
|
||||
chown ${cfg.user}:${cfg.group} ${cfg.stateDir}/.hermes/config.yaml
|
||||
chmod 0644 ${cfg.stateDir}/.hermes/config.yaml
|
||||
''}
|
||||
|
||||
# Managed mode marker (so interactive shells also detect NixOS management)
|
||||
touch ${cfg.stateDir}/.hermes/.managed
|
||||
chown ${cfg.user}:${cfg.group} ${cfg.stateDir}/.hermes/.managed
|
||||
|
||||
# Seed auth file if provided
|
||||
${lib.optionalString (cfg.authFile != null) ''
|
||||
${if cfg.authFileForceOverwrite then ''
|
||||
install -o ${cfg.user} -g ${cfg.group} -m 0600 ${cfg.authFile} ${cfg.stateDir}/.hermes/auth.json
|
||||
'' else ''
|
||||
if [ ! -f ${cfg.stateDir}/.hermes/auth.json ]; then
|
||||
install -o ${cfg.user} -g ${cfg.group} -m 0600 ${cfg.authFile} ${cfg.stateDir}/.hermes/auth.json
|
||||
fi
|
||||
''}
|
||||
''}
|
||||
|
||||
# Seed .env from Nix-declared environment + environmentFiles.
|
||||
# Hermes reads $HERMES_HOME/.env at startup via load_hermes_dotenv(),
|
||||
# so this is the single source of truth for both native and container mode.
|
||||
${lib.optionalString (cfg.environment != {} || cfg.environmentFiles != []) ''
|
||||
ENV_FILE="${cfg.stateDir}/.hermes/.env"
|
||||
install -o ${cfg.user} -g ${cfg.group} -m 0600 /dev/null "$ENV_FILE"
|
||||
cat > "$ENV_FILE" <<'HERMES_NIX_ENV_EOF'
|
||||
${envFileContent}
|
||||
HERMES_NIX_ENV_EOF
|
||||
${lib.concatStringsSep "\n" (map (f: ''
|
||||
if [ -f "${f}" ]; then
|
||||
echo "" >> "$ENV_FILE"
|
||||
cat "${f}" >> "$ENV_FILE"
|
||||
fi
|
||||
'') cfg.environmentFiles)}
|
||||
''}
|
||||
|
||||
# Link documents into workspace
|
||||
${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: _value: ''
|
||||
install -o ${cfg.user} -g ${cfg.group} -m 0644 ${documentDerivation}/${name} ${cfg.workingDirectory}/${name}
|
||||
'') cfg.documents)}
|
||||
'';
|
||||
}
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
# MODE A: Native systemd service (default)
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
(lib.mkIf (!cfg.container.enable) {
|
||||
systemd.services.hermes-agent = {
|
||||
description = "Hermes Agent Gateway";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network-online.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
|
||||
environment = {
|
||||
HOME = cfg.stateDir;
|
||||
HERMES_HOME = "${cfg.stateDir}/.hermes";
|
||||
HERMES_MANAGED = "true";
|
||||
MESSAGING_CWD = cfg.workingDirectory;
|
||||
};
|
||||
|
||||
serviceConfig = {
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
WorkingDirectory = cfg.workingDirectory;
|
||||
|
||||
# cfg.environment and cfg.environmentFiles are written to
|
||||
# $HERMES_HOME/.env by the activation script. load_hermes_dotenv()
|
||||
# reads them at Python startup — no systemd EnvironmentFile needed.
|
||||
|
||||
ExecStart = lib.concatStringsSep " " ([
|
||||
"${cfg.package}/bin/hermes"
|
||||
"gateway"
|
||||
] ++ cfg.extraArgs);
|
||||
|
||||
Restart = cfg.restart;
|
||||
RestartSec = cfg.restartSec;
|
||||
|
||||
# Hardening
|
||||
NoNewPrivileges = true;
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = false;
|
||||
ReadWritePaths = [ cfg.stateDir ];
|
||||
PrivateTmp = true;
|
||||
};
|
||||
|
||||
path = [
|
||||
cfg.package
|
||||
pkgs.bash
|
||||
pkgs.coreutils
|
||||
pkgs.git
|
||||
] ++ cfg.extraPackages;
|
||||
};
|
||||
})
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
# MODE B: OCI container (persistent writable layer)
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
(lib.mkIf cfg.container.enable {
|
||||
# Ensure the container runtime is available
|
||||
virtualisation.docker.enable = lib.mkDefault (cfg.container.backend == "docker");
|
||||
|
||||
systemd.services.hermes-agent = {
|
||||
description = "Hermes Agent Gateway (container)";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network-online.target" ]
|
||||
++ lib.optional (cfg.container.backend == "docker") "docker.service";
|
||||
wants = [ "network-online.target" ];
|
||||
requires = lib.optional (cfg.container.backend == "docker") "docker.service";
|
||||
|
||||
preStart = ''
|
||||
# Stable symlinks — container references these, not store paths directly
|
||||
ln -sfn ${cfg.package} ${cfg.stateDir}/current-package
|
||||
ln -sfn ${containerEntrypoint} ${cfg.stateDir}/current-entrypoint
|
||||
|
||||
# GC roots so nix-collect-garbage doesn't remove store paths in use
|
||||
${pkgs.nix}/bin/nix-store --add-root ${cfg.stateDir}/.gc-root --indirect -r ${cfg.package} 2>/dev/null || true
|
||||
${pkgs.nix}/bin/nix-store --add-root ${cfg.stateDir}/.gc-root-entrypoint --indirect -r ${containerEntrypoint} 2>/dev/null || true
|
||||
|
||||
# Check if container needs (re)creation
|
||||
NEED_CREATE=false
|
||||
if ! ${containerBin} inspect ${containerName} &>/dev/null; then
|
||||
NEED_CREATE=true
|
||||
elif [ ! -f ${identityFile} ] || [ "$(cat ${identityFile})" != "${containerIdentity}" ]; then
|
||||
echo "Container config changed, recreating..."
|
||||
${containerBin} rm -f ${containerName} || true
|
||||
NEED_CREATE=true
|
||||
fi
|
||||
|
||||
if [ "$NEED_CREATE" = "true" ]; then
|
||||
# Resolve numeric UID/GID — passed to entrypoint for in-container user setup
|
||||
HERMES_UID=$(${pkgs.coreutils}/bin/id -u ${cfg.user})
|
||||
HERMES_GID=$(${pkgs.coreutils}/bin/id -g ${cfg.user})
|
||||
|
||||
echo "Creating container..."
|
||||
${containerBin} create \
|
||||
--name ${containerName} \
|
||||
--network=host \
|
||||
--entrypoint ${containerDataDir}/current-entrypoint \
|
||||
--volume /nix/store:/nix/store:ro \
|
||||
--volume ${cfg.stateDir}:${containerDataDir} \
|
||||
--volume ${cfg.stateDir}/home:${containerHomeDir} \
|
||||
${lib.concatStringsSep " " (map (v: "--volume ${v}") cfg.container.extraVolumes)} \
|
||||
--env HERMES_UID="$HERMES_UID" \
|
||||
--env HERMES_GID="$HERMES_GID" \
|
||||
--env HERMES_HOME=${containerDataDir}/.hermes \
|
||||
--env HERMES_MANAGED=true \
|
||||
--env HOME=${containerHomeDir} \
|
||||
--env MESSAGING_CWD=${containerWorkDir} \
|
||||
${lib.concatStringsSep " " cfg.container.extraOptions} \
|
||||
${cfg.container.image} \
|
||||
${containerDataDir}/current-package/bin/hermes gateway run --replace ${lib.concatStringsSep " " cfg.extraArgs}
|
||||
|
||||
echo "${containerIdentity}" > ${identityFile}
|
||||
fi
|
||||
'';
|
||||
|
||||
script = ''
|
||||
exec ${containerBin} start -a ${containerName}
|
||||
'';
|
||||
|
||||
preStop = ''
|
||||
${containerBin} stop -t 10 ${containerName} || true
|
||||
'';
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
Restart = cfg.restart;
|
||||
RestartSec = cfg.restartSec;
|
||||
TimeoutStopSec = 30;
|
||||
};
|
||||
};
|
||||
})
|
||||
]);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
# nix/packages.nix — Hermes Agent package built with uv2nix
|
||||
{ inputs, ... }: {
|
||||
perSystem = { pkgs, system, ... }:
|
||||
let
|
||||
hermesVenv = pkgs.callPackage ./python.nix {
|
||||
inherit (inputs) uv2nix pyproject-nix pyproject-build-systems;
|
||||
};
|
||||
|
||||
# Import bundled skills, excluding runtime caches
|
||||
bundledSkills = pkgs.lib.cleanSourceWith {
|
||||
src = ../skills;
|
||||
filter = path: _type:
|
||||
!(pkgs.lib.hasInfix "/index-cache/" path);
|
||||
};
|
||||
|
||||
runtimeDeps = with pkgs; [
|
||||
nodejs_20 ripgrep git openssh ffmpeg
|
||||
];
|
||||
|
||||
runtimePath = pkgs.lib.makeBinPath runtimeDeps;
|
||||
in {
|
||||
packages.default = pkgs.stdenv.mkDerivation {
|
||||
pname = "hermes-agent";
|
||||
version = "0.1.0";
|
||||
|
||||
dontUnpack = true;
|
||||
dontBuild = true;
|
||||
nativeBuildInputs = [ pkgs.makeWrapper ];
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/share/hermes-agent $out/bin
|
||||
cp -r ${bundledSkills} $out/share/hermes-agent/skills
|
||||
|
||||
${pkgs.lib.concatMapStringsSep "\n" (name: ''
|
||||
makeWrapper ${hermesVenv}/bin/${name} $out/bin/${name} \
|
||||
--prefix PATH : "${runtimePath}" \
|
||||
--set HERMES_BUNDLED_SKILLS $out/share/hermes-agent/skills
|
||||
'') [ "hermes" "hermes-agent" "hermes-acp" ]}
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "AI agent with advanced tool-calling capabilities";
|
||||
homepage = "https://github.com/NousResearch/hermes-agent";
|
||||
mainProgram = "hermes";
|
||||
license = licenses.mit;
|
||||
platforms = platforms.unix;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# nix/python.nix — uv2nix virtual environment builder
|
||||
{
|
||||
python311,
|
||||
lib,
|
||||
callPackage,
|
||||
uv2nix,
|
||||
pyproject-nix,
|
||||
pyproject-build-systems,
|
||||
}:
|
||||
let
|
||||
workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ./..; };
|
||||
|
||||
overlay = workspace.mkPyprojectOverlay {
|
||||
sourcePreference = "wheel";
|
||||
};
|
||||
|
||||
pythonSet =
|
||||
(callPackage pyproject-nix.build.packages {
|
||||
python = python311;
|
||||
}).overrideScope
|
||||
(lib.composeManyExtensions [
|
||||
pyproject-build-systems.overlays.default
|
||||
overlay
|
||||
]);
|
||||
in
|
||||
pythonSet.mkVirtualEnv "hermes-agent-env" {
|
||||
hermes-agent = [ "all" ];
|
||||
}
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
---
|
||||
name: docker-management
|
||||
description: Manage Docker containers, images, volumes, networks, and Compose stacks — lifecycle ops, debugging, cleanup, and Dockerfile optimization.
|
||||
version: 1.0.0
|
||||
author: sprmn24
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [docker, containers, devops, infrastructure, compose, images, volumes, networks, debugging]
|
||||
category: devops
|
||||
requires_toolsets: [terminal]
|
||||
---
|
||||
|
||||
# Docker Management
|
||||
|
||||
Manage Docker containers, images, volumes, networks, and Compose stacks using standard Docker CLI commands. No additional dependencies beyond Docker itself.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Run, stop, restart, remove, or inspect containers
|
||||
- Build, pull, push, tag, or clean up Docker images
|
||||
- Work with Docker Compose (multi-service stacks)
|
||||
- Manage volumes or networks
|
||||
- Debug a crashing container or analyze logs
|
||||
- Check Docker disk usage or free up space
|
||||
- Review or optimize a Dockerfile
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker Engine installed and running
|
||||
- User added to the `docker` group (or use `sudo`)
|
||||
- Docker Compose v2 (included with modern Docker installations)
|
||||
|
||||
Quick check:
|
||||
|
||||
```bash
|
||||
docker --version && docker compose version
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Run container (background) | `docker run -d --name NAME IMAGE` |
|
||||
| Stop + remove | `docker stop NAME && docker rm NAME` |
|
||||
| View logs (follow) | `docker logs --tail 50 -f NAME` |
|
||||
| Shell into container | `docker exec -it NAME /bin/sh` |
|
||||
| List all containers | `docker ps -a` |
|
||||
| Build image | `docker build -t TAG .` |
|
||||
| Compose up | `docker compose up -d` |
|
||||
| Compose down | `docker compose down` |
|
||||
| Disk usage | `docker system df` |
|
||||
| Cleanup dangling | `docker image prune && docker container prune` |
|
||||
|
||||
## Procedure
|
||||
|
||||
### 1. Identify the domain
|
||||
|
||||
Figure out which area the request falls into:
|
||||
|
||||
- **Container lifecycle** → run, stop, start, restart, rm, pause/unpause
|
||||
- **Container interaction** → exec, cp, logs, inspect, stats
|
||||
- **Image management** → build, pull, push, tag, rmi, save/load
|
||||
- **Docker Compose** → up, down, ps, logs, exec, build, config
|
||||
- **Volumes & networks** → create, inspect, rm, prune, connect
|
||||
- **Troubleshooting** → log analysis, exit codes, resource issues
|
||||
|
||||
### 2. Container operations
|
||||
|
||||
**Run a new container:**
|
||||
|
||||
```bash
|
||||
# Detached service with port mapping
|
||||
docker run -d --name web -p 8080:80 nginx
|
||||
|
||||
# With environment variables
|
||||
docker run -d -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=mydb --name db postgres:16
|
||||
|
||||
# With persistent data (named volume)
|
||||
docker run -d -v pgdata:/var/lib/postgresql/data --name db postgres:16
|
||||
|
||||
# For development (bind mount source code)
|
||||
docker run -d -v $(pwd)/src:/app/src -p 3000:3000 --name dev my-app
|
||||
|
||||
# Interactive debugging (auto-remove on exit)
|
||||
docker run -it --rm ubuntu:22.04 /bin/bash
|
||||
|
||||
# With resource limits and restart policy
|
||||
docker run -d --memory=512m --cpus=1.5 --restart=unless-stopped --name app my-app
|
||||
```
|
||||
|
||||
Key flags: `-d` detached, `-it` interactive+tty, `--rm` auto-remove, `-p` port (host:container), `-e` env var, `-v` volume, `--name` name, `--restart` restart policy.
|
||||
|
||||
**Manage running containers:**
|
||||
|
||||
```bash
|
||||
docker ps # running containers
|
||||
docker ps -a # all (including stopped)
|
||||
docker stop NAME # graceful stop
|
||||
docker start NAME # start stopped container
|
||||
docker restart NAME # stop + start
|
||||
docker rm NAME # remove stopped container
|
||||
docker rm -f NAME # force remove running container
|
||||
docker container prune # remove ALL stopped containers
|
||||
```
|
||||
|
||||
**Interact with containers:**
|
||||
|
||||
```bash
|
||||
docker exec -it NAME /bin/sh # shell access (use /bin/bash if available)
|
||||
docker exec NAME env # view environment variables
|
||||
docker exec -u root NAME apt update # run as specific user
|
||||
docker logs --tail 100 -f NAME # follow last 100 lines
|
||||
docker logs --since 2h NAME # logs from last 2 hours
|
||||
docker cp NAME:/path/file ./local # copy file from container
|
||||
docker cp ./file NAME:/path/ # copy file to container
|
||||
docker inspect NAME # full container details (JSON)
|
||||
docker stats --no-stream # resource usage snapshot
|
||||
docker top NAME # running processes
|
||||
```
|
||||
|
||||
### 3. Image management
|
||||
|
||||
```bash
|
||||
# Build
|
||||
docker build -t my-app:latest .
|
||||
docker build -t my-app:prod -f Dockerfile.prod .
|
||||
docker build --no-cache -t my-app . # clean rebuild
|
||||
DOCKER_BUILDKIT=1 docker build -t my-app . # faster with BuildKit
|
||||
|
||||
# Pull and push
|
||||
docker pull node:20-alpine
|
||||
docker login ghcr.io
|
||||
docker tag my-app:latest registry/my-app:v1.0
|
||||
docker push registry/my-app:v1.0
|
||||
|
||||
# Inspect
|
||||
docker images # list local images
|
||||
docker history IMAGE # see layers
|
||||
docker inspect IMAGE # full details
|
||||
|
||||
# Cleanup
|
||||
docker image prune # remove dangling (untagged) images
|
||||
docker image prune -a # remove ALL unused images (careful!)
|
||||
docker image prune -a --filter "until=168h" # unused images older than 7 days
|
||||
```
|
||||
|
||||
### 4. Docker Compose
|
||||
|
||||
```bash
|
||||
# Start/stop
|
||||
docker compose up -d # start all services detached
|
||||
docker compose up -d --build # rebuild images before starting
|
||||
docker compose down # stop and remove containers
|
||||
docker compose down -v # also remove volumes (DESTROYS DATA)
|
||||
|
||||
# Monitoring
|
||||
docker compose ps # list services
|
||||
docker compose logs -f api # follow logs for specific service
|
||||
docker compose logs --tail 50 # last 50 lines all services
|
||||
|
||||
# Interaction
|
||||
docker compose exec api /bin/sh # shell into running service
|
||||
docker compose run --rm api npm test # one-off command (new container)
|
||||
docker compose restart api # restart specific service
|
||||
|
||||
# Validation
|
||||
docker compose config # validate and view resolved config
|
||||
```
|
||||
|
||||
**Minimal compose.yml example:**
|
||||
|
||||
```yaml
|
||||
services:
|
||||
api:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- DATABASE_URL=postgres://user:pass@db:5432/mydb
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: user
|
||||
POSTGRES_PASSWORD: pass
|
||||
POSTGRES_DB: mydb
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U user"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
```
|
||||
|
||||
### 5. Volumes and networks
|
||||
|
||||
```bash
|
||||
# Volumes
|
||||
docker volume ls # list volumes
|
||||
docker volume create mydata # create named volume
|
||||
docker volume inspect mydata # details (mount point, etc.)
|
||||
docker volume rm mydata # remove (fails if in use)
|
||||
docker volume prune # remove unused volumes
|
||||
|
||||
# Networks
|
||||
docker network ls # list networks
|
||||
docker network create mynet # create bridge network
|
||||
docker network inspect mynet # details (connected containers)
|
||||
docker network connect mynet NAME # attach container to network
|
||||
docker network disconnect mynet NAME # detach container
|
||||
docker network rm mynet # remove network
|
||||
docker network prune # remove unused networks
|
||||
```
|
||||
|
||||
### 6. Disk usage and cleanup
|
||||
|
||||
Always start with a diagnostic before cleaning:
|
||||
|
||||
```bash
|
||||
# Check what's using space
|
||||
docker system df # summary
|
||||
docker system df -v # detailed breakdown
|
||||
|
||||
# Targeted cleanup (safe)
|
||||
docker container prune # stopped containers
|
||||
docker image prune # dangling images
|
||||
docker volume prune # unused volumes
|
||||
docker network prune # unused networks
|
||||
|
||||
# Aggressive cleanup (confirm with user first!)
|
||||
docker system prune # containers + images + networks
|
||||
docker system prune -a # also unused images
|
||||
docker system prune -a --volumes # EVERYTHING — named volumes too
|
||||
```
|
||||
|
||||
**Warning:** Never run `docker system prune -a --volumes` without confirming with the user. This removes named volumes with potentially important data.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
| Problem | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| Container exits immediately | Main process finished or crashed | Check `docker logs NAME`, try `docker run -it --entrypoint /bin/sh IMAGE` |
|
||||
| "port is already allocated" | Another process using that port | `docker ps` or `lsof -i :PORT` to find it |
|
||||
| "no space left on device" | Docker disk full | `docker system df` then targeted prune |
|
||||
| Can't connect to container | App binds to 127.0.0.1 inside container | App must bind to `0.0.0.0`, check `-p` mapping |
|
||||
| Permission denied on volume | UID/GID mismatch host vs container | Use `--user $(id -u):$(id -g)` or fix permissions |
|
||||
| Compose services can't reach each other | Wrong network or service name | Services use service name as hostname, check `docker compose config` |
|
||||
| Build cache not working | Layer order wrong in Dockerfile | Put rarely-changing layers first (deps before source code) |
|
||||
| Image too large | No multi-stage build, no .dockerignore | Use multi-stage builds, add `.dockerignore` |
|
||||
|
||||
## Verification
|
||||
|
||||
After any Docker operation, verify the result:
|
||||
|
||||
- **Container started?** → `docker ps` (check status is "Up")
|
||||
- **Logs clean?** → `docker logs --tail 20 NAME` (no errors)
|
||||
- **Port accessible?** → `curl -s http://localhost:PORT` or `docker port NAME`
|
||||
- **Image built?** → `docker images | grep TAG`
|
||||
- **Compose stack healthy?** → `docker compose ps` (all services "running" or "healthy")
|
||||
- **Disk freed?** → `docker system df` (compare before/after)
|
||||
|
||||
## Dockerfile Optimization Tips
|
||||
|
||||
When reviewing or creating a Dockerfile, suggest these improvements:
|
||||
|
||||
1. **Multi-stage builds** — separate build environment from runtime to reduce final image size
|
||||
2. **Layer ordering** — put dependencies before source code so changes don't invalidate cached layers
|
||||
3. **Combine RUN commands** — fewer layers, smaller image
|
||||
4. **Use .dockerignore** — exclude `node_modules`, `.git`, `__pycache__`, etc.
|
||||
5. **Pin base image versions** — `node:20-alpine` not `node:latest`
|
||||
6. **Run as non-root** — add `USER` instruction for security
|
||||
7. **Use slim/alpine bases** — `python:3.12-slim` not `python:3.12`
|
||||
Generated
+56
-24
@@ -523,9 +523,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/basic-ftp": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.1.0.tgz",
|
||||
"integrity": "sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==",
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz",
|
||||
"integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
@@ -1252,10 +1252,10 @@
|
||||
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.3.7",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.7.tgz",
|
||||
"integrity": "sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA==",
|
||||
"node_modules/fast-xml-builder": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
|
||||
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -1264,7 +1264,24 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"strnum": "^2.1.2"
|
||||
"path-expression-matcher": "^1.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.5.9",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.9.tgz",
|
||||
"integrity": "sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-xml-builder": "^1.1.4",
|
||||
"path-expression-matcher": "^1.2.0",
|
||||
"strnum": "^2.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
@@ -1764,12 +1781,12 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -1962,6 +1979,21 @@
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-expression-matcher": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz",
|
||||
"integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
@@ -2105,9 +2137,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/readdir-glob/node_modules/minimatch": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
|
||||
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
|
||||
"version": "5.1.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
||||
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
@@ -2513,9 +2545,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
|
||||
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz",
|
||||
"integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -2615,9 +2647,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.22.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
|
||||
"integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
|
||||
"version": "7.24.6",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz",
|
||||
"integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
@@ -2734,9 +2766,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webdriver/node_modules/undici": {
|
||||
"version": "6.23.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
|
||||
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
|
||||
"version": "6.24.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz",
|
||||
"integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@ dependencies = [
|
||||
"rich>=14.3.3,<15",
|
||||
"tenacity>=9.1.4,<10",
|
||||
"pyyaml>=6.0.2,<7",
|
||||
"requests>=2.32.3,<3",
|
||||
"requests>=2.33.0,<3", # CVE-2026-25645
|
||||
"jinja2>=3.1.5,<4",
|
||||
"pydantic>=2.12.5,<3",
|
||||
# Interactive CLI (prompt_toolkit is used directly by cli.py)
|
||||
@@ -33,7 +33,7 @@ dependencies = [
|
||||
"edge-tts>=7.2.7,<8",
|
||||
"faster-whisper>=1.0.0,<2",
|
||||
# Skills Hub (GitHub App JWT auth — optional, only needed for bot identity)
|
||||
"PyJWT[crypto]>=2.10.1,<3",
|
||||
"PyJWT[crypto]>=2.12.0,<3", # CVE-2026-32597
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -29,7 +29,7 @@ import yaml
|
||||
|
||||
# Load .env from ~/.hermes/.env first, then project root as dev fallback.
|
||||
# User-managed env files should override stale shell exports on restart.
|
||||
_hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||
_hermes_home = get_hermes_home()
|
||||
_project_env = Path(__file__).parent / '.env'
|
||||
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
@@ -53,15 +53,14 @@ else:
|
||||
|
||||
# Import agent and tools
|
||||
from run_agent import AIAgent
|
||||
from model_tools import get_tool_definitions, check_toolset_requirements
|
||||
from tools.rl_training_tool import check_rl_api_keys, get_missing_keys
|
||||
from tools.rl_training_tool import get_missing_keys
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Config Loading
|
||||
# ============================================================================
|
||||
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
from hermes_constants import get_hermes_home, OPENROUTER_BASE_URL
|
||||
|
||||
DEFAULT_MODEL = "anthropic/claude-opus-4.5"
|
||||
DEFAULT_BASE_URL = OPENROUTER_BASE_URL
|
||||
|
||||
+243
-77
@@ -45,11 +45,13 @@ import fire
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
# Load .env from ~/.hermes/.env first, then project root as dev fallback.
|
||||
# User-managed env files should override stale shell exports on restart.
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
|
||||
_hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||
_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)
|
||||
if _loaded_env_paths:
|
||||
@@ -65,7 +67,6 @@ from tools.terminal_tool import cleanup_vm
|
||||
from tools.interrupt import set_interrupt as _set_interrupt
|
||||
from tools.browser_tool import cleanup_browser
|
||||
|
||||
import requests
|
||||
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
|
||||
@@ -162,11 +163,15 @@ def _install_safe_stdio() -> None:
|
||||
|
||||
|
||||
class IterationBudget:
|
||||
"""Thread-safe shared iteration counter for parent and child agents.
|
||||
"""Thread-safe iteration counter for an agent.
|
||||
|
||||
Tracks total LLM-call iterations consumed across a parent agent and all
|
||||
its subagents. A single ``IterationBudget`` is created by the parent
|
||||
and passed to every child so they share the same cap.
|
||||
Each agent (parent or subagent) gets its own ``IterationBudget``.
|
||||
The parent's budget is capped at ``max_iterations`` (default 90).
|
||||
Each subagent gets an independent budget capped at
|
||||
``delegation.max_iterations`` (default 50) — this means total
|
||||
iterations across parent + subagents can exceed the parent's cap.
|
||||
Users control the per-subagent limit via ``delegation.max_iterations``
|
||||
in config.yaml.
|
||||
|
||||
``execute_code`` (programmatic tool calling) iterations are refunded via
|
||||
:meth:`refund` so they don't eat into the budget.
|
||||
@@ -519,7 +524,7 @@ class AIAgent:
|
||||
# Pre-warm OpenRouter model metadata cache in a background thread.
|
||||
# fetch_model_metadata() is cached for 1 hour; this avoids a blocking
|
||||
# HTTP request on the first API response when pricing is estimated.
|
||||
if self.provider == "openrouter" or "openrouter" in self._base_url_lower:
|
||||
if self.provider == "openrouter" or self._is_openrouter_url():
|
||||
threading.Thread(
|
||||
target=lambda: fetch_model_metadata(),
|
||||
daemon=True,
|
||||
@@ -569,7 +574,7 @@ class AIAgent:
|
||||
# Anthropic prompt caching: auto-enabled for Claude models via OpenRouter.
|
||||
# Reduces input costs by ~75% on multi-turn conversations by caching the
|
||||
# conversation prefix. Uses system_and_3 strategy (4 breakpoints).
|
||||
is_openrouter = "openrouter" in self._base_url_lower
|
||||
is_openrouter = self._is_openrouter_url()
|
||||
is_claude = "claude" in self.model.lower()
|
||||
is_native_anthropic = self.api_mode == "anthropic_messages"
|
||||
self._use_prompt_caching = (is_openrouter and is_claude) or is_native_anthropic
|
||||
@@ -689,6 +694,7 @@ class AIAgent:
|
||||
# raw_codex=True because the main agent needs direct responses.stream()
|
||||
# access for Codex Responses API streaming.
|
||||
self._anthropic_client = None
|
||||
self._is_anthropic_oauth = False
|
||||
|
||||
if self.api_mode == "anthropic_messages":
|
||||
from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token
|
||||
@@ -852,7 +858,7 @@ class AIAgent:
|
||||
self.session_id = f"{timestamp_str}_{short_uuid}"
|
||||
|
||||
# Session logs go into ~/.hermes/sessions/ alongside gateway sessions
|
||||
hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||
hermes_home = get_hermes_home()
|
||||
self.logs_dir = hermes_home / "sessions"
|
||||
self.logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.session_log_file = self.logs_dir / f"session_{self.session_id}.json"
|
||||
@@ -887,7 +893,8 @@ class AIAgent:
|
||||
user_id=None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Session DB create_session failed: %s", e)
|
||||
logger.warning("Session DB create_session failed — messages will NOT be indexed: %s", e)
|
||||
self._session_db = None # prevent silent data loss on every subsequent flush
|
||||
|
||||
# In-memory todo list for task planning (one per agent/session)
|
||||
from tools.todo_tool import TodoStore
|
||||
@@ -1116,6 +1123,9 @@ class AIAgent:
|
||||
self.session_cost_status = "unknown"
|
||||
self.session_cost_source = "none"
|
||||
|
||||
# Turn counter (added after reset_session_state was first written — #2635)
|
||||
self._user_turn_count = 0
|
||||
|
||||
# Context compressor internal counters (if present)
|
||||
if hasattr(self, "context_compressor") and self.context_compressor:
|
||||
self.context_compressor.last_prompt_tokens = 0
|
||||
@@ -1123,6 +1133,8 @@ class AIAgent:
|
||||
self.context_compressor.last_total_tokens = 0
|
||||
self.context_compressor.compression_count = 0
|
||||
self.context_compressor._context_probed = False
|
||||
# Iterative summary from previous session must not bleed into new one (#2635)
|
||||
self.context_compressor._previous_summary = None
|
||||
|
||||
def _safe_print(self, *args, **kwargs):
|
||||
"""Print that silently handles broken pipes / closed stdout.
|
||||
@@ -1167,6 +1179,14 @@ class AIAgent:
|
||||
url = (base_url or self._base_url_lower).lower()
|
||||
return "api.openai.com" in url and "openrouter" not in url
|
||||
|
||||
def _is_openrouter_url(self) -> bool:
|
||||
"""Return True when the base URL targets OpenRouter."""
|
||||
return "openrouter" in self._base_url_lower
|
||||
|
||||
def _is_anthropic_url(self) -> bool:
|
||||
"""Return True when the base URL targets Anthropic (native or /anthropic proxy path)."""
|
||||
return "api.anthropic.com" in self._base_url_lower or self._base_url_lower.rstrip("/").endswith("/anthropic")
|
||||
|
||||
def _max_tokens_param(self, value: int) -> dict:
|
||||
"""Return the correct max tokens kwarg for the current provider.
|
||||
|
||||
@@ -1321,6 +1341,24 @@ class AIAgent:
|
||||
summary = detail.get('summary') or detail.get('content') or detail.get('text')
|
||||
if summary and summary not in reasoning_parts:
|
||||
reasoning_parts.append(summary)
|
||||
|
||||
# Some providers embed reasoning directly inside assistant content
|
||||
# instead of returning structured reasoning fields. Only fall back
|
||||
# to inline extraction when no structured reasoning was found.
|
||||
content = getattr(assistant_message, "content", None)
|
||||
if not reasoning_parts and isinstance(content, str) and content:
|
||||
inline_patterns = (
|
||||
r"<think>(.*?)</think>",
|
||||
r"<thinking>(.*?)</thinking>",
|
||||
r"<reasoning>(.*?)</reasoning>",
|
||||
r"<REASONING_SCRATCHPAD>(.*?)</REASONING_SCRATCHPAD>",
|
||||
)
|
||||
for pattern in inline_patterns:
|
||||
flags = re.DOTALL | re.IGNORECASE
|
||||
for block in re.findall(pattern, content, flags=flags):
|
||||
cleaned = block.strip()
|
||||
if cleaned and cleaned not in reasoning_parts:
|
||||
reasoning_parts.append(cleaned)
|
||||
|
||||
# Combine all reasoning parts
|
||||
if reasoning_parts:
|
||||
@@ -1546,7 +1584,7 @@ class AIAgent:
|
||||
)
|
||||
self._last_flushed_db_idx = len(messages)
|
||||
except Exception as e:
|
||||
logger.debug("Session DB append_message failed: %s", e)
|
||||
logger.warning("Session DB append_message failed: %s", e)
|
||||
|
||||
def _get_messages_up_to_last_assistant(self, messages: List[Dict]) -> List[Dict]:
|
||||
"""
|
||||
@@ -1702,7 +1740,7 @@ class AIAgent:
|
||||
while j < len(messages) and messages[j]["role"] == "tool":
|
||||
tool_msg = messages[j]
|
||||
# Format tool response with XML tags
|
||||
tool_response = f"<tool_response>\n"
|
||||
tool_response = "<tool_response>\n"
|
||||
|
||||
# Try to parse tool content as JSON if it looks like JSON
|
||||
tool_content = tool_msg["content"]
|
||||
@@ -1783,6 +1821,47 @@ class AIAgent:
|
||||
trajectory = self._convert_to_trajectory_format(messages, user_query, completed)
|
||||
_save_trajectory_to_file(trajectory, self.model, completed)
|
||||
|
||||
@staticmethod
|
||||
def _summarize_api_error(error: Exception) -> str:
|
||||
"""Extract a human-readable one-liner from an API error.
|
||||
|
||||
Handles Cloudflare HTML error pages (502, 503, etc.) by pulling the
|
||||
<title> tag instead of dumping raw HTML. Falls back to a truncated
|
||||
str(error) for everything else.
|
||||
"""
|
||||
import re as _re
|
||||
raw = str(error)
|
||||
|
||||
# Cloudflare / proxy HTML pages: grab the <title> for a clean summary
|
||||
if "<!DOCTYPE" in raw or "<html" in raw:
|
||||
m = _re.search(r"<title[^>]*>([^<]+)</title>", raw, _re.IGNORECASE)
|
||||
title = m.group(1).strip() if m else "HTML error page (title not found)"
|
||||
# Also grab Cloudflare Ray ID if present
|
||||
ray = _re.search(r"Cloudflare Ray ID:\s*<strong[^>]*>([^<]+)</strong>", raw)
|
||||
ray_id = ray.group(1).strip() if ray else None
|
||||
status_code = getattr(error, "status_code", None)
|
||||
parts = []
|
||||
if status_code:
|
||||
parts.append(f"HTTP {status_code}")
|
||||
parts.append(title)
|
||||
if ray_id:
|
||||
parts.append(f"Ray {ray_id}")
|
||||
return " — ".join(parts)
|
||||
|
||||
# JSON body errors from OpenAI/Anthropic SDKs
|
||||
body = getattr(error, "body", None)
|
||||
if isinstance(body, dict):
|
||||
msg = body.get("error", {}).get("message") if isinstance(body.get("error"), dict) else body.get("message")
|
||||
if msg:
|
||||
status_code = getattr(error, "status_code", None)
|
||||
prefix = f"HTTP {status_code}: " if status_code else ""
|
||||
return f"{prefix}{msg[:300]}"
|
||||
|
||||
# Fallback: truncate the raw string but give more room than 200 chars
|
||||
status_code = getattr(error, "status_code", None)
|
||||
prefix = f"HTTP {status_code}: " if status_code else ""
|
||||
return f"{prefix}{raw[:500]}"
|
||||
|
||||
def _mask_api_key_for_logs(self, key: Optional[str]) -> Optional[str]:
|
||||
if not key:
|
||||
return None
|
||||
@@ -1790,6 +1869,32 @@ class AIAgent:
|
||||
return "***"
|
||||
return f"{key[:8]}...{key[-4:]}"
|
||||
|
||||
def _clean_error_message(self, error_msg: str) -> str:
|
||||
"""
|
||||
Clean up error messages for user display, removing HTML content and truncating.
|
||||
|
||||
Args:
|
||||
error_msg: Raw error message from API or exception
|
||||
|
||||
Returns:
|
||||
Clean, user-friendly error message
|
||||
"""
|
||||
if not error_msg:
|
||||
return "Unknown error"
|
||||
|
||||
# Remove HTML content (common with CloudFlare and gateway error pages)
|
||||
if error_msg.strip().startswith('<!DOCTYPE html') or '<html' in error_msg:
|
||||
return "Service temporarily unavailable (HTML error page returned)"
|
||||
|
||||
# Remove newlines and excessive whitespace
|
||||
cleaned = ' '.join(error_msg.split())
|
||||
|
||||
# Truncate if too long
|
||||
if len(cleaned) > 150:
|
||||
cleaned = cleaned[:150] + "..."
|
||||
|
||||
return cleaned
|
||||
|
||||
def _dump_api_request_debug(
|
||||
self,
|
||||
api_kwargs: Dict[str, Any],
|
||||
@@ -1968,7 +2073,7 @@ class AIAgent:
|
||||
except Exception as e:
|
||||
logger.debug("Failed to propagate interrupt to child agent: %s", e)
|
||||
if not self.quiet_mode:
|
||||
print(f"\n⚡ Interrupt requested" + (f": '{message[:40]}...'" if message and len(message) > 40 else f": '{message}'" if message else ""))
|
||||
print("\n⚡ Interrupt requested" + (f": '{message[:40]}...'" if message and len(message) > 40 else f": '{message}'" if message else ""))
|
||||
|
||||
def clear_interrupt(self) -> None:
|
||||
"""Clear any pending interrupt request and the global tool interrupt signal."""
|
||||
@@ -3577,6 +3682,10 @@ class AIAgent:
|
||||
request_client_holder = {"client": None}
|
||||
first_delta_fired = {"done": False}
|
||||
deltas_were_sent = {"yes": False} # Track if any deltas were fired (for fallback)
|
||||
# Wall-clock timestamp of the last real streaming chunk. The outer
|
||||
# poll loop uses this to detect stale connections that keep receiving
|
||||
# SSE keep-alive pings but no actual data.
|
||||
last_chunk_time = {"t": time.time()}
|
||||
|
||||
def _fire_first_delta():
|
||||
if not first_delta_fired["done"] and on_first_delta:
|
||||
@@ -3617,6 +3726,8 @@ class AIAgent:
|
||||
usage_obj = None
|
||||
|
||||
for chunk in stream:
|
||||
last_chunk_time["t"] = time.time()
|
||||
|
||||
if self._interrupt_requested:
|
||||
break
|
||||
|
||||
@@ -3656,6 +3767,7 @@ class AIAgent:
|
||||
"id": tc_delta.id or "",
|
||||
"type": "function",
|
||||
"function": {"name": "", "arguments": ""},
|
||||
"extra_content": None,
|
||||
}
|
||||
entry = tool_calls_acc[idx]
|
||||
if tc_delta.id:
|
||||
@@ -3665,6 +3777,13 @@ class AIAgent:
|
||||
entry["function"]["name"] += tc_delta.function.name
|
||||
if tc_delta.function.arguments:
|
||||
entry["function"]["arguments"] += tc_delta.function.arguments
|
||||
extra = getattr(tc_delta, "extra_content", None)
|
||||
if extra is None and hasattr(tc_delta, "model_extra"):
|
||||
extra = (tc_delta.model_extra or {}).get("extra_content")
|
||||
if extra is not None:
|
||||
if hasattr(extra, "model_dump"):
|
||||
extra = extra.model_dump()
|
||||
entry["extra_content"] = extra
|
||||
# Fire once per tool when the full name is available
|
||||
name = entry["function"]["name"]
|
||||
if name and idx not in tool_gen_notified:
|
||||
@@ -3689,6 +3808,7 @@ class AIAgent:
|
||||
mock_tool_calls.append(SimpleNamespace(
|
||||
id=tc["id"],
|
||||
type=tc["type"],
|
||||
extra_content=tc.get("extra_content"),
|
||||
function=SimpleNamespace(
|
||||
name=tc["function"]["name"],
|
||||
arguments=tc["function"]["arguments"],
|
||||
@@ -3792,10 +3912,8 @@ class AIAgent:
|
||||
)
|
||||
|
||||
if _is_timeout or _is_conn_err:
|
||||
# Transient network / timeout error. Retry the
|
||||
# streaming request with a fresh connection rather
|
||||
# than falling back to non-streaming (which would
|
||||
# hang for up to 15 min on the same dead server).
|
||||
# Transient network / timeout error. Retry the
|
||||
# streaming request with a fresh connection first.
|
||||
if _stream_attempt < _max_stream_retries:
|
||||
logger.info(
|
||||
"Streaming attempt %s/%s failed (%s: %s), "
|
||||
@@ -3813,40 +3931,65 @@ class AIAgent:
|
||||
)
|
||||
request_client_holder["client"] = None
|
||||
continue
|
||||
# Exhausted retries — propagate to outer loop
|
||||
logger.warning(
|
||||
"Streaming exhausted %s retries on transient error: %s",
|
||||
"Streaming exhausted %s retries on transient error, "
|
||||
"falling back to non-streaming: %s",
|
||||
_max_stream_retries + 1,
|
||||
e,
|
||||
)
|
||||
result["error"] = e
|
||||
return
|
||||
|
||||
# Non-transient error (e.g. "streaming not supported",
|
||||
# auth error, 4xx). Fall back to non-streaming once.
|
||||
err_msg = str(e).lower()
|
||||
if "stream" in err_msg and "not supported" in err_msg:
|
||||
logger.info(
|
||||
"Streaming not supported, falling back to non-streaming: %s", e
|
||||
else:
|
||||
_err_lower = str(e).lower()
|
||||
_is_stream_unsupported = (
|
||||
"stream" in _err_lower
|
||||
and "not supported" in _err_lower
|
||||
)
|
||||
if _is_stream_unsupported:
|
||||
self._safe_print(
|
||||
"\n⚠ Streaming is not supported for this "
|
||||
"model/provider. Falling back to non-streaming.\n"
|
||||
" To avoid this delay, set display.streaming: false "
|
||||
"in config.yaml\n"
|
||||
)
|
||||
logger.info(
|
||||
"Streaming failed before delivery, falling back to non-streaming: %s",
|
||||
e,
|
||||
)
|
||||
try:
|
||||
result["response"] = self._interruptible_api_call(api_kwargs)
|
||||
except Exception as fallback_err:
|
||||
result["error"] = fallback_err
|
||||
return
|
||||
|
||||
# Unknown error — propagate to outer retry loop
|
||||
result["error"] = e
|
||||
try:
|
||||
result["response"] = self._interruptible_api_call(api_kwargs)
|
||||
except Exception as fallback_err:
|
||||
result["error"] = fallback_err
|
||||
return
|
||||
finally:
|
||||
request_client = request_client_holder.get("client")
|
||||
if request_client is not None:
|
||||
self._close_request_openai_client(request_client, reason="stream_request_complete")
|
||||
|
||||
_stream_stale_timeout = float(os.getenv("HERMES_STREAM_STALE_TIMEOUT", 90.0))
|
||||
|
||||
t = threading.Thread(target=_call, daemon=True)
|
||||
t.start()
|
||||
while t.is_alive():
|
||||
t.join(timeout=0.3)
|
||||
|
||||
# Detect stale streams: connections kept alive by SSE pings
|
||||
# but delivering no real chunks. Kill the client so the
|
||||
# inner retry loop can start a fresh connection.
|
||||
if time.time() - last_chunk_time["t"] > _stream_stale_timeout:
|
||||
logger.warning(
|
||||
"Stream stale for %.0fs — no chunks received. Killing connection.",
|
||||
_stream_stale_timeout,
|
||||
)
|
||||
try:
|
||||
rc = request_client_holder.get("client")
|
||||
if rc is not None:
|
||||
self._close_request_openai_client(rc, reason="stale_stream_kill")
|
||||
except Exception:
|
||||
pass
|
||||
# Reset the timer so we don't kill repeatedly while
|
||||
# the inner thread processes the closure.
|
||||
last_chunk_time["t"] = time.time()
|
||||
|
||||
if self._interrupt_requested:
|
||||
try:
|
||||
if self.api_mode == "anthropic_messages":
|
||||
@@ -3925,6 +4068,7 @@ class AIAgent:
|
||||
# Build native Anthropic client instead of using OpenAI client
|
||||
from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token, _is_oauth_token
|
||||
effective_key = (fb_client.api_key or resolve_anthropic_token() or "") if fb_provider == "anthropic" else (fb_client.api_key or "")
|
||||
self.api_key = effective_key
|
||||
self._anthropic_api_key = effective_key
|
||||
self._anthropic_base_url = getattr(fb_client, "base_url", None)
|
||||
self._anthropic_client = build_anthropic_client(effective_key, self._anthropic_base_url)
|
||||
@@ -3933,6 +4077,7 @@ class AIAgent:
|
||||
self._client_kwargs = {}
|
||||
else:
|
||||
# Swap OpenAI client and config in-place
|
||||
self.api_key = fb_client.api_key
|
||||
self.client = fb_client
|
||||
self._client_kwargs = {
|
||||
"api_key": fb_client.api_key,
|
||||
@@ -4121,7 +4266,7 @@ class AIAgent:
|
||||
tools=self.tools,
|
||||
max_tokens=self.max_tokens,
|
||||
reasoning_config=self.reasoning_config,
|
||||
is_oauth=getattr(self, "_is_anthropic_oauth", False),
|
||||
is_oauth=self._is_anthropic_oauth,
|
||||
preserve_dots=self._anthropic_preserve_dots(),
|
||||
)
|
||||
|
||||
@@ -4242,7 +4387,7 @@ class AIAgent:
|
||||
|
||||
extra_body = {}
|
||||
|
||||
_is_openrouter = "openrouter" in self._base_url_lower
|
||||
_is_openrouter = self._is_openrouter_url()
|
||||
_is_github_models = (
|
||||
"models.github.ai" in self._base_url_lower
|
||||
or "api.githubcopilot.com" in self._base_url_lower
|
||||
@@ -4605,7 +4750,7 @@ class AIAgent:
|
||||
tool_calls = assistant_msg.tool_calls
|
||||
elif self.api_mode == "anthropic_messages" and not _aux_available:
|
||||
from agent.anthropic_adapter import normalize_anthropic_response as _nar_flush
|
||||
_flush_msg, _ = _nar_flush(response, strip_tool_prefix=getattr(self, '_is_anthropic_oauth', False))
|
||||
_flush_msg, _ = _nar_flush(response, strip_tool_prefix=self._is_anthropic_oauth)
|
||||
if _flush_msg and _flush_msg.tool_calls:
|
||||
tool_calls = _flush_msg.tool_calls
|
||||
elif hasattr(response, "choices") and response.choices:
|
||||
@@ -4687,7 +4832,7 @@ class AIAgent:
|
||||
# Reset flush cursor — new session starts with no messages written
|
||||
self._last_flushed_db_idx = 0
|
||||
except Exception as e:
|
||||
logger.debug("Session DB compression split failed: %s", e)
|
||||
logger.warning("Session DB compression split failed — new session will NOT be indexed: %s", e)
|
||||
|
||||
# Reset context pressure warning and token estimate — usage drops
|
||||
# after compaction. Without this, the stale last_prompt_tokens from
|
||||
@@ -5406,10 +5551,10 @@ class AIAgent:
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs as _bak, normalize_anthropic_response as _nar
|
||||
_ant_kw = _bak(model=self.model, messages=api_messages, tools=None,
|
||||
max_tokens=self.max_tokens, reasoning_config=self.reasoning_config,
|
||||
is_oauth=getattr(self, '_is_anthropic_oauth', False),
|
||||
is_oauth=self._is_anthropic_oauth,
|
||||
preserve_dots=self._anthropic_preserve_dots())
|
||||
summary_response = self._anthropic_messages_create(_ant_kw)
|
||||
_msg, _ = _nar(summary_response, strip_tool_prefix=getattr(self, '_is_anthropic_oauth', False))
|
||||
_msg, _ = _nar(summary_response, strip_tool_prefix=self._is_anthropic_oauth)
|
||||
final_response = (_msg.content or "").strip()
|
||||
else:
|
||||
summary_response = self._ensure_primary_openai_client(reason="iteration_limit_summary").chat.completions.create(**summary_kwargs)
|
||||
@@ -5437,11 +5582,11 @@ class AIAgent:
|
||||
elif self.api_mode == "anthropic_messages":
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs as _bak2, normalize_anthropic_response as _nar2
|
||||
_ant_kw2 = _bak2(model=self.model, messages=api_messages, tools=None,
|
||||
is_oauth=getattr(self, '_is_anthropic_oauth', False),
|
||||
is_oauth=self._is_anthropic_oauth,
|
||||
max_tokens=self.max_tokens, reasoning_config=self.reasoning_config,
|
||||
preserve_dots=self._anthropic_preserve_dots())
|
||||
retry_response = self._anthropic_messages_create(_ant_kw2)
|
||||
_retry_msg, _ = _nar2(retry_response, strip_tool_prefix=getattr(self, '_is_anthropic_oauth', False))
|
||||
_retry_msg, _ = _nar2(retry_response, strip_tool_prefix=self._is_anthropic_oauth)
|
||||
final_response = (_retry_msg.content or "").strip()
|
||||
else:
|
||||
summary_kwargs = {
|
||||
@@ -5703,13 +5848,13 @@ class AIAgent:
|
||||
if self._interrupt_requested:
|
||||
interrupted = True
|
||||
if not self.quiet_mode:
|
||||
self._safe_print(f"\n⚡ Breaking out of tool loop due to interrupt...")
|
||||
self._safe_print("\n⚡ Breaking out of tool loop due to interrupt...")
|
||||
break
|
||||
|
||||
api_call_count += 1
|
||||
if not self.iteration_budget.consume():
|
||||
if not self.quiet_mode:
|
||||
self._safe_print(f"\n⚠️ Session iteration budget exhausted ({self.iteration_budget.max_total} total across agent + subagents)")
|
||||
self._safe_print(f"\n⚠️ Iteration budget exhausted ({self.iteration_budget.used}/{self.iteration_budget.max_total} iterations used)")
|
||||
break
|
||||
|
||||
# Fire step_callback for gateway hooks (agent:step event)
|
||||
@@ -5932,7 +6077,7 @@ class AIAgent:
|
||||
if response_invalid:
|
||||
# Stop spinner before printing error messages
|
||||
if thinking_spinner:
|
||||
thinking_spinner.stop(f"(´;ω;`) oops, retrying...")
|
||||
thinking_spinner.stop("(´;ω;`) oops, retrying...")
|
||||
thinking_spinner = None
|
||||
if self.thinking_callback:
|
||||
self.thinking_callback("")
|
||||
@@ -5971,7 +6116,8 @@ class AIAgent:
|
||||
|
||||
self._vprint(f"{self.log_prefix}⚠️ Invalid API response (attempt {retry_count}/{max_retries}): {', '.join(error_details)}", force=True)
|
||||
self._vprint(f"{self.log_prefix} 🏢 Provider: {provider_name}", force=True)
|
||||
self._vprint(f"{self.log_prefix} 📝 Provider message: {error_msg[:200]}", force=True)
|
||||
cleaned_provider_error = self._clean_error_message(error_msg)
|
||||
self._vprint(f"{self.log_prefix} 📝 Provider message: {cleaned_provider_error}", force=True)
|
||||
self._vprint(f"{self.log_prefix} ⏱️ Response time: {api_duration:.2f}s (fast response often indicates rate limiting)", force=True)
|
||||
|
||||
if retry_count >= max_retries:
|
||||
@@ -6213,7 +6359,7 @@ class AIAgent:
|
||||
except Exception as api_error:
|
||||
# Stop spinner before printing error messages
|
||||
if thinking_spinner:
|
||||
thinking_spinner.stop(f"(╥_╥) error, retrying...")
|
||||
thinking_spinner.stop("(╥_╥) error, retrying...")
|
||||
thinking_spinner = None
|
||||
if self.thinking_callback:
|
||||
self.thinking_callback("")
|
||||
@@ -6267,25 +6413,31 @@ class AIAgent:
|
||||
retry_count += 1
|
||||
elapsed_time = time.time() - api_start_time
|
||||
|
||||
# Enhanced error logging
|
||||
error_type = type(api_error).__name__
|
||||
error_msg = str(api_error).lower()
|
||||
_error_summary = self._summarize_api_error(api_error)
|
||||
logger.warning(
|
||||
"API call failed (attempt %s/%s) error_type=%s %s error=%s",
|
||||
"API call failed (attempt %s/%s) error_type=%s %s summary=%s",
|
||||
retry_count,
|
||||
max_retries,
|
||||
error_type,
|
||||
self._client_log_context(),
|
||||
api_error,
|
||||
_error_summary,
|
||||
)
|
||||
|
||||
_provider = getattr(self, "provider", "unknown")
|
||||
_base = getattr(self, "base_url", "unknown")
|
||||
_model = getattr(self, "model", "unknown")
|
||||
self._vprint(f"{self.log_prefix}⚠️ API call failed (attempt {retry_count}/{max_retries}): {error_type}", force=True)
|
||||
_status_code_str = f" [HTTP {status_code}]" if status_code else ""
|
||||
self._vprint(f"{self.log_prefix}⚠️ API call failed (attempt {retry_count}/{max_retries}): {error_type}{_status_code_str}", force=True)
|
||||
self._vprint(f"{self.log_prefix} 🔌 Provider: {_provider} Model: {_model}", force=True)
|
||||
self._vprint(f"{self.log_prefix} 🌐 Endpoint: {_base}", force=True)
|
||||
self._vprint(f"{self.log_prefix} 📝 Error: {str(api_error)[:200]}", force=True)
|
||||
self._vprint(f"{self.log_prefix} 📝 Error: {_error_summary}", force=True)
|
||||
if status_code and status_code < 500:
|
||||
_err_body = getattr(api_error, "body", None)
|
||||
_err_body_str = str(_err_body)[:300] if _err_body else None
|
||||
if _err_body_str:
|
||||
self._vprint(f"{self.log_prefix} 📋 Details: {_err_body_str}", force=True)
|
||||
self._vprint(f"{self.log_prefix} ⏱️ Elapsed: {elapsed_time:.2f}s Context: {len(api_messages)} msgs, ~{approx_tokens:,} tokens")
|
||||
|
||||
# Check for interrupt before deciding to retry
|
||||
@@ -6294,7 +6446,7 @@ class AIAgent:
|
||||
self._persist_session(messages, conversation_history)
|
||||
self.clear_interrupt()
|
||||
return {
|
||||
"final_response": f"Operation interrupted: handling API error ({error_type}: {str(api_error)[:80]}).",
|
||||
"final_response": f"Operation interrupted: handling API error ({error_type}: {self._clean_error_message(str(api_error))}).",
|
||||
"messages": messages,
|
||||
"api_calls": api_call_count,
|
||||
"completed": False,
|
||||
@@ -6378,6 +6530,7 @@ class AIAgent:
|
||||
'exceeds the limit', 'context window',
|
||||
'request entity too large', # OpenRouter/Nous 413 safety net
|
||||
'prompt is too long', # Anthropic: "prompt is too long: N tokens > M maximum"
|
||||
'prompt exceeds max length', # Z.AI / GLM: generic 400 overflow wording
|
||||
])
|
||||
|
||||
# Fallback heuristic: Anthropic sometimes returns a generic
|
||||
@@ -6536,9 +6689,17 @@ class AIAgent:
|
||||
if self._try_activate_fallback():
|
||||
retry_count = 0
|
||||
continue
|
||||
_final_summary = self._summarize_api_error(api_error)
|
||||
self._vprint(f"{self.log_prefix}❌ Max retries ({max_retries}) exceeded. Giving up.", force=True)
|
||||
logging.error(f"{self.log_prefix}API call failed after {max_retries} retries. Last error: {api_error}")
|
||||
logging.error(f"{self.log_prefix}Request details - Messages: {len(api_messages)}, Approx tokens: {approx_tokens:,}")
|
||||
self._vprint(f"{self.log_prefix} 💀 Final error: {_final_summary}", force=True)
|
||||
logging.error(
|
||||
"%sAPI call failed after %s retries. %s | provider=%s model=%s msgs=%s tokens=~%s",
|
||||
self.log_prefix, max_retries, _final_summary,
|
||||
_provider, _model, len(api_messages), f"{approx_tokens:,}",
|
||||
)
|
||||
self._dump_api_request_debug(
|
||||
api_kwargs, reason="max_retries_exhausted", error=api_error,
|
||||
)
|
||||
raise api_error
|
||||
|
||||
wait_time = min(2 ** retry_count, 60) # Exponential backoff: 2s, 4s, 8s, 16s, 32s, 60s, 60s
|
||||
@@ -6574,6 +6735,11 @@ class AIAgent:
|
||||
if restart_with_compressed_messages:
|
||||
api_call_count -= 1
|
||||
self.iteration_budget.refund()
|
||||
# Count compression restarts toward the retry limit to prevent
|
||||
# infinite loops when compression reduces messages but not enough
|
||||
# to fit the context window.
|
||||
retry_count += 1
|
||||
restart_with_compressed_messages = False
|
||||
continue
|
||||
|
||||
if restart_with_length_continuation:
|
||||
@@ -6593,7 +6759,7 @@ class AIAgent:
|
||||
elif self.api_mode == "anthropic_messages":
|
||||
from agent.anthropic_adapter import normalize_anthropic_response
|
||||
assistant_message, finish_reason = normalize_anthropic_response(
|
||||
response, strip_tool_prefix=getattr(self, "_is_anthropic_oauth", False)
|
||||
response, strip_tool_prefix=self._is_anthropic_oauth
|
||||
)
|
||||
else:
|
||||
assistant_message = response.choices[0].message
|
||||
@@ -6781,7 +6947,7 @@ class AIAgent:
|
||||
if tc.function.name not in self.valid_tool_names:
|
||||
content = f"Tool '{tc.function.name}' does not exist. Available tools: {available}"
|
||||
else:
|
||||
content = f"Skipped: another tool call in this turn used an invalid name. Please retry this tool call."
|
||||
content = "Skipped: another tool call in this turn used an invalid name. Please retry this tool call."
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.id,
|
||||
@@ -7168,7 +7334,7 @@ class AIAgent:
|
||||
or self.iteration_budget.remaining <= 0
|
||||
):
|
||||
if self.iteration_budget.remaining <= 0 and not self.quiet_mode:
|
||||
print(f"\n⚠️ Session iteration budget exhausted ({self.iteration_budget.used}/{self.iteration_budget.max_total} used, including subagents)")
|
||||
print(f"\n⚠️ Iteration budget exhausted ({self.iteration_budget.used}/{self.iteration_budget.max_total} iterations used)")
|
||||
final_response = self._handle_max_iterations(messages, api_call_count)
|
||||
|
||||
# Determine if conversation completed successfully
|
||||
@@ -7376,20 +7542,20 @@ def main(
|
||||
toolset = get_toolset_for_tool(tool_name)
|
||||
print(f" 📌 {tool_name} (from {toolset})")
|
||||
|
||||
print(f"\n💡 Usage Examples:")
|
||||
print(f" # Use predefined toolsets")
|
||||
print(f" python run_agent.py --enabled_toolsets=research --query='search for Python news'")
|
||||
print(f" python run_agent.py --enabled_toolsets=development --query='debug this code'")
|
||||
print(f" python run_agent.py --enabled_toolsets=safe --query='analyze without terminal'")
|
||||
print(f" ")
|
||||
print(f" # Combine multiple toolsets")
|
||||
print(f" python run_agent.py --enabled_toolsets=web,vision --query='analyze website'")
|
||||
print(f" ")
|
||||
print(f" # Disable toolsets")
|
||||
print(f" python run_agent.py --disabled_toolsets=terminal --query='no command execution'")
|
||||
print(f" ")
|
||||
print(f" # Run with trajectory saving enabled")
|
||||
print(f" python run_agent.py --save_trajectories --query='your question here'")
|
||||
print("\n💡 Usage Examples:")
|
||||
print(" # Use predefined toolsets")
|
||||
print(" python run_agent.py --enabled_toolsets=research --query='search for Python news'")
|
||||
print(" python run_agent.py --enabled_toolsets=development --query='debug this code'")
|
||||
print(" python run_agent.py --enabled_toolsets=safe --query='analyze without terminal'")
|
||||
print(" ")
|
||||
print(" # Combine multiple toolsets")
|
||||
print(" python run_agent.py --enabled_toolsets=web,vision --query='analyze website'")
|
||||
print(" ")
|
||||
print(" # Disable toolsets")
|
||||
print(" python run_agent.py --disabled_toolsets=terminal --query='no command execution'")
|
||||
print(" ")
|
||||
print(" # Run with trajectory saving enabled")
|
||||
print(" python run_agent.py --save_trajectories --query='your question here'")
|
||||
return
|
||||
|
||||
# Parse toolset selection arguments
|
||||
@@ -7405,9 +7571,9 @@ def main(
|
||||
print(f"🚫 Disabled toolsets: {disabled_toolsets_list}")
|
||||
|
||||
if save_trajectories:
|
||||
print(f"💾 Trajectory saving: ENABLED")
|
||||
print(f" - Successful conversations → trajectory_samples.jsonl")
|
||||
print(f" - Failed conversations → failed_trajectories.jsonl")
|
||||
print("💾 Trajectory saving: ENABLED")
|
||||
print(" - Successful conversations → trajectory_samples.jsonl")
|
||||
print(" - Failed conversations → failed_trajectories.jsonl")
|
||||
|
||||
# Initialize agent with provided parameters
|
||||
try:
|
||||
@@ -7449,7 +7615,7 @@ def main(
|
||||
print(f"💬 Messages: {len(result['messages'])}")
|
||||
|
||||
if result['final_response']:
|
||||
print(f"\n🎯 FINAL RESPONSE:")
|
||||
print("\n🎯 FINAL RESPONSE:")
|
||||
print("-" * 30)
|
||||
print(result['final_response'])
|
||||
|
||||
|
||||
@@ -307,9 +307,8 @@ class TestExpiredCodexFallback:
|
||||
|
||||
|
||||
def test_hermes_oauth_file_sets_oauth_flag(self, monkeypatch):
|
||||
"""Hermes OAuth credentials should get is_oauth=True (token is not sk-ant-api-*)."""
|
||||
"""OAuth-style tokens should get is_oauth=True (token is not sk-ant-api-*)."""
|
||||
# Mock resolve_anthropic_token to return an OAuth-style token
|
||||
# (simulates what read_hermes_oauth_credentials would return)
|
||||
with patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="hermes-oauth-jwt-token"), \
|
||||
patch("agent.anthropic_adapter.build_anthropic_client") as mock_build:
|
||||
mock_build.return_value = MagicMock()
|
||||
|
||||
@@ -464,14 +464,15 @@ class TestBuildContextFilesPrompt:
|
||||
result = build_context_files_prompt(cwd=str(tmp_path))
|
||||
assert "ESLint" in result
|
||||
|
||||
def test_recursive_agents_md(self, tmp_path):
|
||||
def test_agents_md_top_level_only(self, tmp_path):
|
||||
"""AGENTS.md is loaded from cwd only — subdirectory copies are ignored."""
|
||||
(tmp_path / "AGENTS.md").write_text("Top level instructions.")
|
||||
sub = tmp_path / "src"
|
||||
sub.mkdir()
|
||||
(sub / "AGENTS.md").write_text("Src-specific instructions.")
|
||||
result = build_context_files_prompt(cwd=str(tmp_path))
|
||||
assert "Top level" in result
|
||||
assert "Src-specific" in result
|
||||
assert "Src-specific" not in result
|
||||
|
||||
# --- .hermes.md / HERMES.md discovery ---
|
||||
|
||||
@@ -878,3 +879,32 @@ class TestBuildSkillsSystemPromptConditional:
|
||||
)
|
||||
result = build_skills_system_prompt()
|
||||
assert "duckduckgo" in result
|
||||
|
||||
def test_null_metadata_does_not_crash(self, monkeypatch, tmp_path):
|
||||
"""Regression: metadata key present but null should not AttributeError."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
skill_dir = tmp_path / "skills" / "general" / "safe-skill"
|
||||
skill_dir.mkdir(parents=True)
|
||||
# YAML `metadata:` with no value parses as {"metadata": None}
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: safe-skill\ndescription: Survives null metadata\nmetadata:\n---\n"
|
||||
)
|
||||
result = build_skills_system_prompt(
|
||||
available_tools=set(),
|
||||
available_toolsets=set(),
|
||||
)
|
||||
assert "safe-skill" in result
|
||||
|
||||
def test_null_hermes_under_metadata_does_not_crash(self, monkeypatch, tmp_path):
|
||||
"""Regression: metadata.hermes present but null should not crash."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
skill_dir = tmp_path / "skills" / "general" / "nested-null"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: nested-null\ndescription: Null hermes key\nmetadata:\n hermes:\n---\n"
|
||||
)
|
||||
result = build_skills_system_prompt(
|
||||
available_tools=set(),
|
||||
available_toolsets=set(),
|
||||
)
|
||||
assert "nested-null" in result
|
||||
|
||||
@@ -254,6 +254,10 @@ class TestRunJobSessionPersistence:
|
||||
assert kwargs["session_db"] is fake_db
|
||||
assert kwargs["platform"] == "cron"
|
||||
assert kwargs["session_id"].startswith("cron_test-job_")
|
||||
fake_db.end_session.assert_called_once()
|
||||
call_args = fake_db.end_session.call_args
|
||||
assert call_args[0][0].startswith("cron_test-job_")
|
||||
assert call_args[0][1] == "cron_complete"
|
||||
fake_db.close.assert_called_once()
|
||||
|
||||
def test_run_job_empty_response_returns_empty_not_placeholder(self, tmp_path):
|
||||
|
||||
@@ -198,12 +198,12 @@ async def test_command_messages_do_not_leave_sentinel():
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Test 6: /stop during sentinel returns helpful message
|
||||
# Test 6: /stop during sentinel force-cleans and unlocks session
|
||||
# ------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_during_sentinel_returns_message():
|
||||
async def test_stop_during_sentinel_force_cleans_session():
|
||||
"""If /stop arrives while the sentinel is set (agent still starting),
|
||||
it should return a helpful message instead of crashing or queuing."""
|
||||
it should force-clean the sentinel and unlock the session."""
|
||||
runner = _make_runner()
|
||||
event1 = _make_event(text="hello")
|
||||
session_key = build_session_key(event1.source)
|
||||
@@ -221,11 +221,16 @@ async def test_stop_during_sentinel_returns_message():
|
||||
# Sentinel should be set
|
||||
assert runner._running_agents.get(session_key) is _AGENT_PENDING_SENTINEL
|
||||
|
||||
# Send /stop — should get a message, not crash
|
||||
# Send /stop — should force-clean the sentinel
|
||||
stop_event = _make_event(text="/stop")
|
||||
result = await runner._handle_message(stop_event)
|
||||
assert result is not None, "/stop during sentinel should return a message"
|
||||
assert "starting up" in result.lower()
|
||||
assert "force-stopped" in result.lower() or "unlocked" in result.lower()
|
||||
|
||||
# Sentinel must be cleaned up
|
||||
assert session_key not in runner._running_agents, (
|
||||
"/stop must remove sentinel so the session is unlocked"
|
||||
)
|
||||
|
||||
# Should NOT be queued as pending
|
||||
adapter = runner.adapters[Platform.TELEGRAM]
|
||||
@@ -235,6 +240,73 @@ async def test_stop_during_sentinel_returns_message():
|
||||
await task1
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Test 6b: /stop hard-kills a running agent and unlocks session
|
||||
# ------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_hard_kills_running_agent():
|
||||
"""When /stop arrives while a real agent is running, it must:
|
||||
1. Call interrupt() on the agent
|
||||
2. Force-clean _running_agents to unlock the session
|
||||
3. Return a confirmation message
|
||||
This fixes the bug where a hung agent kept the session locked
|
||||
forever — showing 'writing...' but never producing output."""
|
||||
runner = _make_runner()
|
||||
session_key = build_session_key(
|
||||
SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm")
|
||||
)
|
||||
|
||||
# Simulate a running (possibly hung) agent
|
||||
fake_agent = MagicMock()
|
||||
runner._running_agents[session_key] = fake_agent
|
||||
|
||||
# Send /stop
|
||||
stop_event = _make_event(text="/stop")
|
||||
result = await runner._handle_message(stop_event)
|
||||
|
||||
# Agent must have been interrupted
|
||||
fake_agent.interrupt.assert_called_once_with("Stop requested")
|
||||
|
||||
# Session must be unlocked
|
||||
assert session_key not in runner._running_agents, (
|
||||
"/stop must remove the agent from _running_agents so the session is unlocked"
|
||||
)
|
||||
|
||||
# Must return a confirmation
|
||||
assert result is not None
|
||||
assert "force-stopped" in result.lower() or "unlocked" in result.lower()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Test 6c: /stop clears pending messages to prevent stale replays
|
||||
# ------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_clears_pending_messages():
|
||||
"""When /stop hard-kills a running agent, any pending messages
|
||||
queued during the run must be discarded."""
|
||||
runner = _make_runner()
|
||||
session_key = build_session_key(
|
||||
SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm")
|
||||
)
|
||||
|
||||
fake_agent = MagicMock()
|
||||
runner._running_agents[session_key] = fake_agent
|
||||
runner._pending_messages[session_key] = "some queued text"
|
||||
|
||||
# Queue a pending message in the adapter too
|
||||
adapter = runner.adapters[Platform.TELEGRAM]
|
||||
adapter._pending_messages[session_key] = _make_event(text="queued")
|
||||
adapter.get_pending_message = MagicMock(return_value=_make_event(text="queued"))
|
||||
adapter.has_pending_interrupt = MagicMock(return_value=False)
|
||||
|
||||
stop_event = _make_event(text="/stop")
|
||||
await runner._handle_message(stop_event)
|
||||
|
||||
# Pending messages must be cleared
|
||||
assert session_key not in runner._pending_messages
|
||||
adapter.get_pending_message.assert_called_once_with(session_key)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Test 7: Shutdown skips sentinel entries
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -946,3 +946,100 @@ class TestFallbackPreservesThreadContext:
|
||||
|
||||
call_kwargs = adapter._app.client.chat_postMessage.call_args.kwargs
|
||||
assert "important screenshot" in call_kwargs["text"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestProgressMessageThread
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestProgressMessageThread:
|
||||
"""Verify that progress messages go to the correct thread.
|
||||
|
||||
Issue #2954: For Slack DM top-level messages, source.thread_id is None
|
||||
but the final reply is threaded under the user's message via reply_to.
|
||||
Progress messages must use the same thread anchor (the original message's
|
||||
ts) so they appear in the thread instead of the DM root.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_toplevel_progress_uses_message_ts_as_thread(self, adapter):
|
||||
"""Progress messages for a top-level DM should go into the reply thread."""
|
||||
# Simulate a top-level DM: no thread_ts in the event
|
||||
event = {
|
||||
"channel": "D_DM",
|
||||
"channel_type": "im",
|
||||
"user": "U_USER",
|
||||
"text": "Hello bot",
|
||||
"ts": "1234567890.000001",
|
||||
# No thread_ts — this is a top-level DM
|
||||
}
|
||||
|
||||
captured_events = []
|
||||
adapter.handle_message = AsyncMock(side_effect=lambda e: captured_events.append(e))
|
||||
|
||||
# Patch _resolve_user_name to avoid async Slack API call
|
||||
with patch.object(adapter, "_resolve_user_name", new=AsyncMock(return_value="testuser")):
|
||||
await adapter._handle_slack_message(event)
|
||||
|
||||
assert len(captured_events) == 1
|
||||
msg_event = captured_events[0]
|
||||
source = msg_event.source
|
||||
|
||||
# For a top-level DM: source.thread_id should remain None
|
||||
# (session keying must not be affected)
|
||||
assert source.thread_id is None, (
|
||||
"source.thread_id must stay None for top-level DMs "
|
||||
"so they share one continuous session"
|
||||
)
|
||||
|
||||
# The message_id should be the event's ts — this is what the gateway
|
||||
# passes as event_message_id so progress messages can thread correctly
|
||||
assert msg_event.message_id == "1234567890.000001", (
|
||||
"message_id must equal the event ts so _run_agent can use it as "
|
||||
"the fallback thread anchor for progress messages"
|
||||
)
|
||||
|
||||
# Verify that the Slack send() method correctly threads a message
|
||||
# when metadata contains thread_id equal to the original ts
|
||||
adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "reply_ts"})
|
||||
result = await adapter.send(
|
||||
chat_id="D_DM",
|
||||
content="⚙️ working...",
|
||||
metadata={"thread_id": msg_event.message_id},
|
||||
)
|
||||
assert result.success
|
||||
call_kwargs = adapter._app.client.chat_postMessage.call_args[1]
|
||||
assert call_kwargs.get("thread_ts") == "1234567890.000001", (
|
||||
"send() must pass thread_ts when metadata has thread_id, "
|
||||
"ensuring progress messages land in the thread"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_mention_progress_uses_thread_ts(self, adapter):
|
||||
"""Progress messages for a channel @mention should go into the reply thread."""
|
||||
# Simulate an @mention in a channel: the event ts becomes the thread anchor
|
||||
event = {
|
||||
"channel": "C_CHAN",
|
||||
"channel_type": "channel",
|
||||
"user": "U_USER",
|
||||
"text": f"<@U_BOT> help me",
|
||||
"ts": "2000000000.000001",
|
||||
# No thread_ts — top-level channel message
|
||||
}
|
||||
|
||||
captured_events = []
|
||||
adapter.handle_message = AsyncMock(side_effect=lambda e: captured_events.append(e))
|
||||
|
||||
with patch.object(adapter, "_resolve_user_name", new=AsyncMock(return_value="testuser")):
|
||||
await adapter._handle_slack_message(event)
|
||||
|
||||
assert len(captured_events) == 1
|
||||
msg_event = captured_events[0]
|
||||
source = msg_event.source
|
||||
|
||||
# For channel @mention: thread_id should equal the event ts (fallback)
|
||||
assert source.thread_id == "2000000000.000001", (
|
||||
"source.thread_id must equal the event ts for channel messages "
|
||||
"so each @mention starts its own thread"
|
||||
)
|
||||
assert msg_event.message_id == "2000000000.000001"
|
||||
|
||||
@@ -389,72 +389,6 @@ class TestSubcommandCompletion:
|
||||
assert completions == []
|
||||
|
||||
|
||||
# ── Two-stage /model completion ─────────────────────────────────────────
|
||||
|
||||
|
||||
def _model_completer() -> SlashCommandCompleter:
|
||||
"""Build a completer with mock model/provider info."""
|
||||
return SlashCommandCompleter(
|
||||
model_completer_provider=lambda: {
|
||||
"current_provider": "openrouter",
|
||||
"providers": {
|
||||
"anthropic": "Anthropic",
|
||||
"openrouter": "OpenRouter",
|
||||
"nous": "Nous Research",
|
||||
},
|
||||
"models_for": lambda p: {
|
||||
"anthropic": ["claude-sonnet-4-20250514", "claude-opus-4-20250414"],
|
||||
"openrouter": ["anthropic/claude-sonnet-4", "google/gemini-2.5-pro"],
|
||||
"nous": ["hermes-3-llama-3.1-405b"],
|
||||
}.get(p, []),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TestModelCompletion:
|
||||
def test_stage1_shows_providers(self):
|
||||
completions = _completions(_model_completer(), "/model ")
|
||||
texts = {c.text for c in completions}
|
||||
assert "anthropic:" in texts
|
||||
assert "openrouter:" in texts
|
||||
assert "nous:" in texts
|
||||
|
||||
def test_stage1_current_provider_last(self):
|
||||
completions = _completions(_model_completer(), "/model ")
|
||||
texts = [c.text for c in completions]
|
||||
assert texts[-1] == "openrouter:"
|
||||
|
||||
def test_stage1_current_provider_labeled(self):
|
||||
completions = _completions(_model_completer(), "/model ")
|
||||
for c in completions:
|
||||
if c.text == "openrouter:":
|
||||
assert "current" in c.display_meta_text.lower()
|
||||
break
|
||||
else:
|
||||
raise AssertionError("openrouter: not found in completions")
|
||||
|
||||
def test_stage1_prefix_filters(self):
|
||||
completions = _completions(_model_completer(), "/model an")
|
||||
texts = {c.text for c in completions}
|
||||
assert texts == {"anthropic:"}
|
||||
|
||||
def test_stage2_shows_models(self):
|
||||
completions = _completions(_model_completer(), "/model anthropic:")
|
||||
texts = {c.text for c in completions}
|
||||
assert "anthropic:claude-sonnet-4-20250514" in texts
|
||||
assert "anthropic:claude-opus-4-20250414" in texts
|
||||
|
||||
def test_stage2_prefix_filters_models(self):
|
||||
completions = _completions(_model_completer(), "/model anthropic:claude-s")
|
||||
texts = {c.text for c in completions}
|
||||
assert "anthropic:claude-sonnet-4-20250514" in texts
|
||||
assert "anthropic:claude-opus-4-20250414" not in texts
|
||||
|
||||
def test_stage2_no_model_provider_returns_empty(self):
|
||||
completions = _completions(SlashCommandCompleter(), "/model ")
|
||||
assert completions == []
|
||||
|
||||
|
||||
# ── Ghost text (SlashCommandAutoSuggest) ────────────────────────────────
|
||||
|
||||
|
||||
@@ -492,15 +426,3 @@ class TestGhostText:
|
||||
|
||||
def test_no_suggestion_for_non_slash(self):
|
||||
assert _suggestion("hello") is None
|
||||
|
||||
def test_model_stage1_ghost_text(self):
|
||||
"""/model a → 'nthropic:'"""
|
||||
completer = _model_completer()
|
||||
assert _suggestion("/model a", completer=completer) == "nthropic:"
|
||||
|
||||
def test_model_stage2_ghost_text(self):
|
||||
"""/model anthropic:cl → rest of first matching model"""
|
||||
completer = _model_completer()
|
||||
s = _suggestion("/model anthropic:cl", completer=completer)
|
||||
assert s is not None
|
||||
assert s.startswith("aude-")
|
||||
|
||||
@@ -62,3 +62,56 @@ def test_sessions_delete_reports_not_found_when_prefix_is_unknown(monkeypatch, c
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "Session 'missing-prefix' not found." in output
|
||||
|
||||
|
||||
def test_sessions_delete_handles_eoferror_on_confirm(monkeypatch, capsys):
|
||||
"""sessions delete should not crash when stdin is closed (non-TTY)."""
|
||||
import hermes_cli.main as main_mod
|
||||
import hermes_state
|
||||
|
||||
class FakeDB:
|
||||
def resolve_session_id(self, session_id):
|
||||
return "20260315_092437_c9a6ff"
|
||||
|
||||
def delete_session(self, session_id):
|
||||
raise AssertionError("delete_session should not be called when cancelled")
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(hermes_state, "SessionDB", lambda: FakeDB())
|
||||
monkeypatch.setattr(
|
||||
sys, "argv",
|
||||
["hermes", "sessions", "delete", "20260315_092437_c9a6"],
|
||||
)
|
||||
monkeypatch.setattr("builtins.input", lambda _prompt="": (_ for _ in ()).throw(EOFError))
|
||||
|
||||
main_mod.main()
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "Cancelled" in output
|
||||
|
||||
|
||||
def test_sessions_prune_handles_eoferror_on_confirm(monkeypatch, capsys):
|
||||
"""sessions prune should not crash when stdin is closed (non-TTY)."""
|
||||
import hermes_cli.main as main_mod
|
||||
import hermes_state
|
||||
|
||||
class FakeDB:
|
||||
def prune_sessions(self, **kwargs):
|
||||
raise AssertionError("prune_sessions should not be called when cancelled")
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(hermes_state, "SessionDB", lambda: FakeDB())
|
||||
monkeypatch.setattr(
|
||||
sys, "argv",
|
||||
["hermes", "sessions", "prune"],
|
||||
)
|
||||
monkeypatch.setattr("builtins.input", lambda _prompt="": (_ for _ in ()).throw(EOFError))
|
||||
|
||||
main_mod.main()
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "Cancelled" in output
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Tests for CLI background command TUI refresh behavior.
|
||||
|
||||
Ensures the TUI is properly refreshed before printing background task output
|
||||
to prevent spinner/status bar overlap (#2718).
|
||||
"""
|
||||
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
def _make_cli():
|
||||
"""Create a minimal HermesCLI instance for testing."""
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
cli_obj.model = "test-model"
|
||||
cli_obj._background_tasks = {}
|
||||
cli_obj._background_task_counter = 0
|
||||
cli_obj.conversation_history = []
|
||||
cli_obj.agent = None
|
||||
cli_obj._app = None
|
||||
return cli_obj
|
||||
|
||||
|
||||
class TestBackgroundCommandTuiRefresh:
|
||||
"""Tests for TUI refresh in background command output."""
|
||||
|
||||
def test_invalidate_called_before_success_output(self):
|
||||
"""App.invalidate() is called before printing background success output."""
|
||||
cli_obj = _make_cli()
|
||||
mock_app = MagicMock()
|
||||
cli_obj._app = mock_app
|
||||
|
||||
# Track call order
|
||||
call_order = []
|
||||
original_invalidate = mock_app.invalidate
|
||||
|
||||
def track_invalidate():
|
||||
call_order.append("invalidate")
|
||||
return original_invalidate()
|
||||
|
||||
mock_app.invalidate = track_invalidate
|
||||
|
||||
# Patch print to track when it's called
|
||||
with patch("builtins.print") as mock_print:
|
||||
mock_print.side_effect = lambda *args, **kwargs: call_order.append("print")
|
||||
|
||||
# Simulate the background task output code path
|
||||
if cli_obj._app:
|
||||
cli_obj._app.invalidate()
|
||||
import time
|
||||
time.sleep(0.01) # reduced for test
|
||||
print()
|
||||
|
||||
# Verify invalidate was called before print
|
||||
assert call_order[0] == "invalidate"
|
||||
assert "print" in call_order
|
||||
|
||||
def test_invalidate_called_before_error_output(self):
|
||||
"""App.invalidate() is called before printing background error output."""
|
||||
cli_obj = _make_cli()
|
||||
mock_app = MagicMock()
|
||||
cli_obj._app = mock_app
|
||||
|
||||
call_order = []
|
||||
mock_app.invalidate.side_effect = lambda: call_order.append("invalidate")
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
mock_print.side_effect = lambda *args, **kwargs: call_order.append("print")
|
||||
|
||||
# Simulate error path
|
||||
if cli_obj._app:
|
||||
cli_obj._app.invalidate()
|
||||
import time
|
||||
time.sleep(0.01)
|
||||
print()
|
||||
|
||||
assert call_order[0] == "invalidate"
|
||||
assert "print" in call_order
|
||||
|
||||
def test_no_crash_when_app_is_none(self):
|
||||
"""No crash when _app is None (non-TUI mode)."""
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._app = None
|
||||
|
||||
# This should not raise
|
||||
if cli_obj._app:
|
||||
cli_obj._app.invalidate()
|
||||
# If we get here without exception, test passes
|
||||
|
||||
def test_background_task_thread_safety(self):
|
||||
"""Background task tracking is thread-safe."""
|
||||
cli_obj = _make_cli()
|
||||
|
||||
# Simulate adding and removing background tasks
|
||||
task_id = "test_task_1"
|
||||
cli_obj._background_tasks[task_id] = MagicMock()
|
||||
assert task_id in cli_obj._background_tasks
|
||||
|
||||
# Clean up
|
||||
cli_obj._background_tasks.pop(task_id, None)
|
||||
assert task_id not in cli_obj._background_tasks
|
||||
@@ -1,132 +0,0 @@
|
||||
"""Regression tests for the `/model` slash command in the interactive CLI."""
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
class TestModelCommand:
|
||||
def _make_cli(self):
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
cli_obj.model = "anthropic/claude-opus-4.6"
|
||||
cli_obj.agent = object()
|
||||
cli_obj.provider = "openrouter"
|
||||
cli_obj.requested_provider = "openrouter"
|
||||
cli_obj.base_url = "https://openrouter.ai/api/v1"
|
||||
cli_obj.api_key = "test-key"
|
||||
cli_obj._explicit_api_key = None
|
||||
cli_obj._explicit_base_url = None
|
||||
return cli_obj
|
||||
|
||||
def test_valid_model_from_api_saved_to_config(self, capsys):
|
||||
cli_obj = self._make_cli()
|
||||
|
||||
with patch("hermes_cli.models.fetch_api_models",
|
||||
return_value=["anthropic/claude-sonnet-4.5", "openai/gpt-5.4"]), \
|
||||
patch("cli.save_config_value", return_value=True) as save_mock:
|
||||
cli_obj.process_command("/model anthropic/claude-sonnet-4.5")
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "saved to config" in output
|
||||
assert cli_obj.model == "anthropic/claude-sonnet-4.5"
|
||||
save_mock.assert_called_once_with("model.default", "anthropic/claude-sonnet-4.5")
|
||||
|
||||
def test_unlisted_model_accepted_with_warning(self, capsys):
|
||||
cli_obj = self._make_cli()
|
||||
|
||||
with patch("hermes_cli.models.fetch_api_models",
|
||||
return_value=["anthropic/claude-opus-4.6"]), \
|
||||
patch("cli.save_config_value") as save_mock:
|
||||
cli_obj.process_command("/model anthropic/fake-model")
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "not found" in output or "Model changed" in output
|
||||
assert cli_obj.model == "anthropic/fake-model" # accepted
|
||||
|
||||
def test_api_unreachable_accepts_and_persists(self, capsys):
|
||||
cli_obj = self._make_cli()
|
||||
|
||||
with patch("hermes_cli.models.fetch_api_models", return_value=None), \
|
||||
patch("cli.save_config_value") as save_mock:
|
||||
cli_obj.process_command("/model anthropic/claude-sonnet-next")
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "saved to config" in output
|
||||
assert cli_obj.model == "anthropic/claude-sonnet-next"
|
||||
save_mock.assert_called_once()
|
||||
|
||||
def test_no_slash_model_accepted_with_warning(self, capsys):
|
||||
cli_obj = self._make_cli()
|
||||
|
||||
with patch("hermes_cli.models.fetch_api_models",
|
||||
return_value=["openai/gpt-5.4"]) as fetch_mock, \
|
||||
patch("cli.save_config_value") as save_mock:
|
||||
cli_obj.process_command("/model gpt-5.4")
|
||||
|
||||
output = capsys.readouterr().out
|
||||
# Auto-detection remaps bare model names to proper OpenRouter slugs
|
||||
assert cli_obj.model == "openai/gpt-5.4"
|
||||
|
||||
def test_validation_crash_falls_back_to_save(self, capsys):
|
||||
cli_obj = self._make_cli()
|
||||
|
||||
with patch("hermes_cli.models.validate_requested_model",
|
||||
side_effect=RuntimeError("boom")), \
|
||||
patch("cli.save_config_value", return_value=True) as save_mock:
|
||||
cli_obj.process_command("/model anthropic/claude-sonnet-4.5")
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "saved to config" in output
|
||||
assert cli_obj.model == "anthropic/claude-sonnet-4.5"
|
||||
save_mock.assert_called_once()
|
||||
|
||||
def test_show_model_when_no_argument(self, capsys):
|
||||
cli_obj = self._make_cli()
|
||||
cli_obj.process_command("/model")
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "anthropic/claude-opus-4.6" in output
|
||||
assert "OpenRouter" in output
|
||||
assert "Authenticated providers" in output or "Switch model" in output
|
||||
assert "provider" in output and "model" in output
|
||||
|
||||
# -- provider switching tests -------------------------------------------
|
||||
|
||||
def test_provider_colon_model_switches_provider(self, capsys):
|
||||
cli_obj = self._make_cli()
|
||||
|
||||
with patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value={
|
||||
"provider": "zai",
|
||||
"api_key": "zai-key",
|
||||
"base_url": "https://api.z.ai/api/paas/v4",
|
||||
}), \
|
||||
patch("hermes_cli.models.fetch_api_models",
|
||||
return_value=["glm-5", "glm-4.7"]), \
|
||||
patch("cli.save_config_value", return_value=True) as save_mock:
|
||||
cli_obj.process_command("/model zai:glm-5")
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "glm-5" in output
|
||||
assert "provider:" in output.lower() or "Z.AI" in output
|
||||
assert cli_obj.model == "glm-5"
|
||||
assert cli_obj.provider == "zai"
|
||||
assert cli_obj.base_url == "https://api.z.ai/api/paas/v4"
|
||||
# Model, provider, and base_url should be saved
|
||||
assert save_mock.call_count == 3
|
||||
save_calls = [c.args for c in save_mock.call_args_list]
|
||||
assert ("model.default", "glm-5") in save_calls
|
||||
assert ("model.provider", "zai") in save_calls
|
||||
# base_url is also persisted on provider change (Phase 2 fix)
|
||||
assert any(c[0] == "model.base_url" for c in save_calls)
|
||||
|
||||
def test_provider_switch_fails_on_bad_credentials(self, capsys):
|
||||
cli_obj = self._make_cli()
|
||||
|
||||
with patch("hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
side_effect=Exception("No API key found")):
|
||||
cli_obj.process_command("/model nous:hermes-3")
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "Could not resolve credentials" in output
|
||||
assert cli_obj.model == "anthropic/claude-opus-4.6" # unchanged
|
||||
assert cli_obj.provider == "openrouter" # unchanged
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Tests for cross-loop client cache isolation fix (#2681).
|
||||
|
||||
Verifies that _get_cached_client() returns different AsyncOpenAI clients
|
||||
when called from different event loops, preventing the httpx deadlock
|
||||
that occurs when a cached async client bound to loop A is reused on loop B.
|
||||
|
||||
This test file is self-contained and does not import the full tool chain,
|
||||
so it can run without optional dependencies like firecrawl.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from unittest.mock import patch, MagicMock
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal stubs so we can import _get_cached_client without the full tree
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _stub_resolve_provider_client(provider, model, async_mode, **kw):
|
||||
"""Return a unique mock client each time, simulating AsyncOpenAI creation."""
|
||||
client = MagicMock(name=f"client-{provider}-async={async_mode}")
|
||||
client.api_key = "test"
|
||||
client.base_url = kw.get("explicit_base_url", "http://localhost:8081/v1")
|
||||
return client, model or "test-model"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_client_cache():
|
||||
"""Clear the client cache before each test."""
|
||||
import importlib
|
||||
# We need to patch before importing
|
||||
with patch.dict("sys.modules", {}):
|
||||
pass
|
||||
# Import and clear
|
||||
import agent.auxiliary_client as ac
|
||||
ac._client_cache.clear()
|
||||
yield
|
||||
ac._client_cache.clear()
|
||||
|
||||
|
||||
class TestCrossLoopCacheIsolation:
|
||||
"""Verify async clients are cached per-event-loop, not globally."""
|
||||
|
||||
def test_same_loop_reuses_client(self):
|
||||
"""Within a single event loop, the same client should be returned."""
|
||||
from agent.auxiliary_client import _get_cached_client, _client_cache
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
with patch("agent.auxiliary_client.resolve_provider_client",
|
||||
side_effect=_stub_resolve_provider_client):
|
||||
client1, _ = _get_cached_client("custom", "m1", async_mode=True,
|
||||
base_url="http://localhost:8081/v1")
|
||||
client2, _ = _get_cached_client("custom", "m1", async_mode=True,
|
||||
base_url="http://localhost:8081/v1")
|
||||
|
||||
assert client1 is client2, (
|
||||
"Same loop should return the same cached client"
|
||||
)
|
||||
loop.close()
|
||||
|
||||
def test_different_loops_get_different_clients(self):
|
||||
"""Different event loops must get separate client instances."""
|
||||
from agent.auxiliary_client import _get_cached_client
|
||||
|
||||
results = {}
|
||||
|
||||
def _get_client_on_new_loop(name):
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
with patch("agent.auxiliary_client.resolve_provider_client",
|
||||
side_effect=_stub_resolve_provider_client):
|
||||
client, _ = _get_cached_client("custom", "m1", async_mode=True,
|
||||
base_url="http://localhost:8081/v1")
|
||||
results[name] = (id(client), id(loop))
|
||||
# Don't close loop — simulates real usage where loops persist
|
||||
|
||||
t1 = threading.Thread(target=_get_client_on_new_loop, args=("a",))
|
||||
t2 = threading.Thread(target=_get_client_on_new_loop, args=("b",))
|
||||
t1.start(); t1.join()
|
||||
t2.start(); t2.join()
|
||||
|
||||
client_id_a, loop_id_a = results["a"]
|
||||
client_id_b, loop_id_b = results["b"]
|
||||
|
||||
assert loop_id_a != loop_id_b, "Test setup error: same loop on both threads"
|
||||
assert client_id_a != client_id_b, (
|
||||
"Different event loops got the SAME cached client — this causes "
|
||||
"httpx cross-loop deadlocks in gateway mode (#2681)"
|
||||
)
|
||||
|
||||
def test_sync_clients_not_affected(self):
|
||||
"""Sync clients (async_mode=False) should still be cached globally,
|
||||
since httpx.Client (sync) doesn't bind to an event loop."""
|
||||
from agent.auxiliary_client import _get_cached_client
|
||||
|
||||
results = {}
|
||||
|
||||
def _get_sync_client(name):
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
with patch("agent.auxiliary_client.resolve_provider_client",
|
||||
side_effect=_stub_resolve_provider_client):
|
||||
client, _ = _get_cached_client("custom", "m1", async_mode=False,
|
||||
base_url="http://localhost:8081/v1")
|
||||
results[name] = id(client)
|
||||
|
||||
t1 = threading.Thread(target=_get_sync_client, args=("a",))
|
||||
t2 = threading.Thread(target=_get_sync_client, args=("b",))
|
||||
t1.start(); t1.join()
|
||||
t2.start(); t2.join()
|
||||
|
||||
assert results["a"] == results["b"], (
|
||||
"Sync clients should be shared across threads (no loop binding)"
|
||||
)
|
||||
|
||||
def test_gateway_simulation_no_deadlock(self):
|
||||
"""Simulate gateway mode: _run_async spawns a thread with asyncio.run(),
|
||||
which creates a new loop. The cached client must be created on THAT loop,
|
||||
not reused from a different one."""
|
||||
from agent.auxiliary_client import _get_cached_client
|
||||
|
||||
# Simulate: first call on "gateway loop"
|
||||
gateway_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(gateway_loop)
|
||||
|
||||
with patch("agent.auxiliary_client.resolve_provider_client",
|
||||
side_effect=_stub_resolve_provider_client):
|
||||
gateway_client, _ = _get_cached_client("custom", "m1", async_mode=True,
|
||||
base_url="http://localhost:8081/v1")
|
||||
|
||||
# Simulate: _run_async spawns a thread with asyncio.run()
|
||||
worker_client_id = [None]
|
||||
def _worker():
|
||||
async def _inner():
|
||||
with patch("agent.auxiliary_client.resolve_provider_client",
|
||||
side_effect=_stub_resolve_provider_client):
|
||||
client, _ = _get_cached_client("custom", "m1", async_mode=True,
|
||||
base_url="http://localhost:8081/v1")
|
||||
worker_client_id[0] = id(client)
|
||||
asyncio.run(_inner())
|
||||
|
||||
t = threading.Thread(target=_worker)
|
||||
t.start()
|
||||
t.join()
|
||||
|
||||
assert worker_client_id[0] != id(gateway_client), (
|
||||
"Worker thread (asyncio.run) got the gateway's cached client — "
|
||||
"this is the exact cross-loop scenario that causes httpx deadlocks. "
|
||||
"The cache key must include the event loop identity (#2681)"
|
||||
)
|
||||
gateway_loop.close()
|
||||
|
||||
def test_closed_loop_client_discarded(self):
|
||||
"""A cached client whose loop has closed should be replaced."""
|
||||
from agent.auxiliary_client import _get_cached_client
|
||||
|
||||
loop1 = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop1)
|
||||
|
||||
with patch("agent.auxiliary_client.resolve_provider_client",
|
||||
side_effect=_stub_resolve_provider_client):
|
||||
client1, _ = _get_cached_client("custom", "m1", async_mode=True,
|
||||
base_url="http://localhost:8081/v1")
|
||||
|
||||
loop1.close()
|
||||
|
||||
# New loop on same thread
|
||||
loop2 = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop2)
|
||||
|
||||
with patch("agent.auxiliary_client.resolve_provider_client",
|
||||
side_effect=_stub_resolve_provider_client):
|
||||
client2, _ = _get_cached_client("custom", "m1", async_mode=True,
|
||||
base_url="http://localhost:8081/v1")
|
||||
|
||||
assert client1 is not client2, (
|
||||
"Client from closed loop should not be reused"
|
||||
)
|
||||
loop2.close()
|
||||
@@ -11,6 +11,7 @@ Combines functionality from:
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
import re
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -295,6 +296,108 @@ class TestReasoningCallback(unittest.TestCase):
|
||||
# No exception = pass
|
||||
|
||||
|
||||
class TestReasoningPreviewBuffering(unittest.TestCase):
|
||||
def _make_cli(self):
|
||||
from cli import HermesCLI
|
||||
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli.verbose = True
|
||||
cli._spinner_text = ""
|
||||
cli._reasoning_preview_buf = ""
|
||||
cli._invalidate = lambda *args, **kwargs: None
|
||||
return cli
|
||||
|
||||
@patch("cli._cprint")
|
||||
def test_streamed_reasoning_chunks_wait_for_boundary(self, mock_cprint):
|
||||
cli = self._make_cli()
|
||||
|
||||
cli._on_reasoning("Let")
|
||||
cli._on_reasoning(" me")
|
||||
cli._on_reasoning(" think")
|
||||
|
||||
self.assertEqual(mock_cprint.call_count, 0)
|
||||
|
||||
cli._on_reasoning(" about this.\n")
|
||||
|
||||
self.assertEqual(mock_cprint.call_count, 1)
|
||||
rendered = mock_cprint.call_args[0][0]
|
||||
self.assertIn("[thinking] Let me think about this.", rendered)
|
||||
|
||||
@patch("cli._cprint")
|
||||
def test_pending_reasoning_flushes_when_thinking_stops(self, mock_cprint):
|
||||
cli = self._make_cli()
|
||||
|
||||
cli._on_reasoning("see")
|
||||
cli._on_reasoning(" how")
|
||||
cli._on_reasoning(" this")
|
||||
cli._on_reasoning(" plays")
|
||||
cli._on_reasoning(" out")
|
||||
|
||||
self.assertEqual(mock_cprint.call_count, 0)
|
||||
|
||||
cli._on_thinking("")
|
||||
|
||||
self.assertEqual(mock_cprint.call_count, 1)
|
||||
rendered = mock_cprint.call_args[0][0]
|
||||
self.assertIn("[thinking] see how this plays out", rendered)
|
||||
|
||||
@patch("cli._cprint")
|
||||
@patch("cli.shutil.get_terminal_size", return_value=SimpleNamespace(columns=50))
|
||||
def test_reasoning_preview_compacts_newlines_and_wraps_to_terminal(self, _mock_term, mock_cprint):
|
||||
cli = self._make_cli()
|
||||
|
||||
cli._emit_reasoning_preview(
|
||||
"First line\nstill same thought\n\n\nSecond paragraph with more detail here."
|
||||
)
|
||||
|
||||
rendered = mock_cprint.call_args[0][0]
|
||||
plain = re.sub(r"\x1b\[[0-9;]*m", "", rendered)
|
||||
normalized = " ".join(plain.split())
|
||||
self.assertIn("[thinking] First line still same thought", plain)
|
||||
self.assertIn("Second paragraph with more detail here.", normalized)
|
||||
self.assertNotIn("\n\n\n", plain)
|
||||
|
||||
@patch("cli.shutil.get_terminal_size", return_value=SimpleNamespace(columns=60))
|
||||
def test_reasoning_flush_threshold_tracks_terminal_width(self, _mock_term):
|
||||
cli = self._make_cli()
|
||||
|
||||
cli._reasoning_preview_buf = "a" * 30
|
||||
cli._flush_reasoning_preview(force=False)
|
||||
self.assertEqual(cli._reasoning_preview_buf, "a" * 30)
|
||||
|
||||
|
||||
class TestReasoningDisplayModeSelection(unittest.TestCase):
|
||||
def _make_cli(self, *, show_reasoning=False, streaming_enabled=False, verbose=False):
|
||||
from cli import HermesCLI
|
||||
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli.show_reasoning = show_reasoning
|
||||
cli.streaming_enabled = streaming_enabled
|
||||
cli.verbose = verbose
|
||||
cli._stream_reasoning_delta = lambda text: ("stream", text)
|
||||
cli._on_reasoning = lambda text: ("preview", text)
|
||||
return cli
|
||||
|
||||
def test_show_reasoning_non_streaming_uses_final_box_only(self):
|
||||
cli = self._make_cli(show_reasoning=True, streaming_enabled=False, verbose=False)
|
||||
|
||||
self.assertIsNone(cli._current_reasoning_callback())
|
||||
|
||||
def test_show_reasoning_streaming_uses_live_reasoning_box(self):
|
||||
cli = self._make_cli(show_reasoning=True, streaming_enabled=True, verbose=False)
|
||||
|
||||
callback = cli._current_reasoning_callback()
|
||||
self.assertIsNotNone(callback)
|
||||
self.assertEqual(callback("x"), ("stream", "x"))
|
||||
|
||||
def test_verbose_without_show_reasoning_uses_preview_callback(self):
|
||||
cli = self._make_cli(show_reasoning=False, streaming_enabled=False, verbose=True)
|
||||
|
||||
callback = cli._current_reasoning_callback()
|
||||
self.assertIsNotNone(callback)
|
||||
self.assertEqual(callback("x"), ("preview", "x"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Real provider format extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+49
-5
@@ -267,6 +267,21 @@ class TestExtractReasoning:
|
||||
result = agent._extract_reasoning(msg)
|
||||
assert result == "same text"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("content", "expected"),
|
||||
[
|
||||
("<think>thinking hard</think>", "thinking hard"),
|
||||
("<thinking>step by step</thinking>", "step by step"),
|
||||
(
|
||||
"<REASONING_SCRATCHPAD>scratch analysis</REASONING_SCRATCHPAD>",
|
||||
"scratch analysis",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_inline_reasoning_blocks_fallback(self, agent, content, expected):
|
||||
msg = _mock_assistant_msg(content=content)
|
||||
assert agent._extract_reasoning(msg) == expected
|
||||
|
||||
|
||||
class TestCleanSessionContent:
|
||||
def test_none_passthrough(self):
|
||||
@@ -1202,8 +1217,8 @@ class TestRunConversation:
|
||||
assert result["completed"] is True
|
||||
assert result["api_calls"] == 2
|
||||
|
||||
def test_empty_content_retry_and_fallback(self, agent):
|
||||
"""Empty content (only think block) retries, then falls back to partial."""
|
||||
def test_empty_content_retry_uses_inline_reasoning_as_response(self, agent):
|
||||
"""Reasoning-only payloads should recover the inline reasoning text."""
|
||||
self._setup_agent(agent)
|
||||
empty_resp = _mock_response(
|
||||
content="<think>internal reasoning</think>",
|
||||
@@ -1221,9 +1236,8 @@ class TestRunConversation:
|
||||
patch.object(agent, "_cleanup_task_resources"),
|
||||
):
|
||||
result = agent.run_conversation("answer me")
|
||||
# After 3 retries with no real content, should return partial
|
||||
assert result["completed"] is False
|
||||
assert result.get("partial") is True
|
||||
assert result["completed"] is True
|
||||
assert result["final_response"] == "internal reasoning"
|
||||
|
||||
def test_nous_401_refreshes_after_remint_and_retries(self, agent):
|
||||
self._setup_agent(agent)
|
||||
@@ -1296,6 +1310,36 @@ class TestRunConversation:
|
||||
assert result["final_response"] == "All done"
|
||||
assert result["completed"] is True
|
||||
|
||||
def test_glm_prompt_exceeds_max_length_triggers_compression(self, agent):
|
||||
"""GLM/Z.AI uses 'Prompt exceeds max length' for context overflow."""
|
||||
self._setup_agent(agent)
|
||||
err_400 = Exception(
|
||||
"Error code: 400 - {'error': {'code': '1261', 'message': 'Prompt exceeds max length'}}"
|
||||
)
|
||||
err_400.status_code = 400
|
||||
ok_resp = _mock_response(content="Recovered after compression", finish_reason="stop")
|
||||
agent.client.chat.completions.create.side_effect = [err_400, ok_resp]
|
||||
prefill = [
|
||||
{"role": "user", "content": "previous question"},
|
||||
{"role": "assistant", "content": "previous answer"},
|
||||
]
|
||||
|
||||
with (
|
||||
patch.object(agent, "_compress_context") as mock_compress,
|
||||
patch.object(agent, "_persist_session"),
|
||||
patch.object(agent, "_save_trajectory"),
|
||||
patch.object(agent, "_cleanup_task_resources"),
|
||||
):
|
||||
mock_compress.return_value = (
|
||||
[{"role": "user", "content": "hello"}],
|
||||
"compressed system prompt",
|
||||
)
|
||||
result = agent.run_conversation("hello", conversation_history=prefill)
|
||||
|
||||
mock_compress.assert_called_once()
|
||||
assert result["final_response"] == "Recovered after compression"
|
||||
assert result["completed"] is True
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("first_content", "second_content", "expected_final"),
|
||||
[
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Tests for session reset completeness (fixes #2635).
|
||||
|
||||
/clear and /new must not carry stale state into the next session.
|
||||
Two fields were added after reset_session_state() was written and were
|
||||
therefore never cleared:
|
||||
- ContextCompressor._previous_summary
|
||||
- AIAgent._user_turn_count
|
||||
"""
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure repo root is importable
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
# Stub out optional heavy dependencies not installed in the test environment
|
||||
sys.modules.setdefault("fire", types.SimpleNamespace(Fire=lambda *a, **k: None))
|
||||
sys.modules.setdefault("firecrawl", types.SimpleNamespace(Firecrawl=object))
|
||||
sys.modules.setdefault("fal_client", types.SimpleNamespace())
|
||||
|
||||
from run_agent import AIAgent
|
||||
from agent.context_compressor import ContextCompressor
|
||||
|
||||
|
||||
def _make_minimal_agent() -> AIAgent:
|
||||
"""Return an AIAgent constructed with the absolute minimum args.
|
||||
|
||||
We pass dummy values that bypass network calls and filesystem access.
|
||||
The object is never used to make API calls — only its attributes and
|
||||
reset_session_state() are exercised.
|
||||
"""
|
||||
agent = AIAgent.__new__(AIAgent) # skip __init__ entirely
|
||||
|
||||
# Seed the exact attributes that reset_session_state() writes
|
||||
agent.session_total_tokens = 0
|
||||
agent.session_input_tokens = 0
|
||||
agent.session_output_tokens = 0
|
||||
agent.session_prompt_tokens = 0
|
||||
agent.session_completion_tokens = 0
|
||||
agent.session_cache_read_tokens = 0
|
||||
agent.session_cache_write_tokens = 0
|
||||
agent.session_reasoning_tokens = 0
|
||||
agent.session_api_calls = 0
|
||||
agent.session_estimated_cost_usd = 0.0
|
||||
agent.session_cost_status = "unknown"
|
||||
agent.session_cost_source = "none"
|
||||
|
||||
# The two fields under test
|
||||
agent._user_turn_count = 0
|
||||
agent.context_compressor = None # will be set per-test as needed
|
||||
|
||||
return agent
|
||||
|
||||
|
||||
class TestResetSessionState:
|
||||
"""reset_session_state() must clear ALL session-scoped state."""
|
||||
|
||||
def test_previous_summary_cleared_on_reset(self):
|
||||
"""Compression summary from old session must not leak into new session."""
|
||||
agent = _make_minimal_agent()
|
||||
compressor = ContextCompressor.__new__(ContextCompressor)
|
||||
compressor._previous_summary = "Old session summary about unrelated topic"
|
||||
# Seed counter attributes that reset_session_state touches
|
||||
compressor.last_prompt_tokens = 100
|
||||
compressor.last_completion_tokens = 50
|
||||
compressor.last_total_tokens = 150
|
||||
compressor.compression_count = 3
|
||||
compressor._context_probed = True
|
||||
|
||||
agent.context_compressor = compressor
|
||||
|
||||
agent.reset_session_state()
|
||||
|
||||
assert compressor._previous_summary is None, (
|
||||
"_previous_summary must be None after reset; got: "
|
||||
f"{compressor._previous_summary!r}"
|
||||
)
|
||||
|
||||
def test_user_turn_count_cleared_on_reset(self):
|
||||
"""Turn counter must reset to 0 on new session."""
|
||||
agent = _make_minimal_agent()
|
||||
agent._user_turn_count = 7 # simulates turns accumulated in previous session
|
||||
agent.context_compressor = None
|
||||
|
||||
agent.reset_session_state()
|
||||
|
||||
assert agent._user_turn_count == 0, (
|
||||
f"_user_turn_count must be 0 after reset; got: {agent._user_turn_count}"
|
||||
)
|
||||
|
||||
def test_both_fields_cleared_together(self):
|
||||
"""Both stale fields are cleared in a single reset_session_state() call."""
|
||||
agent = _make_minimal_agent()
|
||||
agent._user_turn_count = 3
|
||||
|
||||
compressor = ContextCompressor.__new__(ContextCompressor)
|
||||
compressor._previous_summary = "Stale summary"
|
||||
compressor.last_prompt_tokens = 0
|
||||
compressor.last_completion_tokens = 0
|
||||
compressor.last_total_tokens = 0
|
||||
compressor.compression_count = 0
|
||||
compressor._context_probed = False
|
||||
agent.context_compressor = compressor
|
||||
|
||||
agent.reset_session_state()
|
||||
|
||||
assert agent._user_turn_count == 0
|
||||
assert compressor._previous_summary is None
|
||||
|
||||
def test_reset_without_compressor_does_not_raise(self):
|
||||
"""reset_session_state() must not raise when context_compressor is None."""
|
||||
agent = _make_minimal_agent()
|
||||
agent._user_turn_count = 2
|
||||
agent.context_compressor = None
|
||||
|
||||
# Must not raise
|
||||
agent.reset_session_state()
|
||||
|
||||
assert agent._user_turn_count == 0
|
||||
+98
-2
@@ -39,10 +39,15 @@ def _make_stream_chunk(
|
||||
return chunk
|
||||
|
||||
|
||||
def _make_tool_call_delta(index=0, tc_id=None, name=None, arguments=None):
|
||||
def _make_tool_call_delta(index=0, tc_id=None, name=None, arguments=None, extra_content=None, model_extra=None):
|
||||
"""Build a mock tool call delta."""
|
||||
func = SimpleNamespace(name=name, arguments=arguments)
|
||||
return SimpleNamespace(index=index, id=tc_id, function=func)
|
||||
delta = SimpleNamespace(index=index, id=tc_id, function=func)
|
||||
if extra_content is not None:
|
||||
delta.extra_content = extra_content
|
||||
if model_extra is not None:
|
||||
delta.model_extra = model_extra
|
||||
return delta
|
||||
|
||||
|
||||
def _make_empty_chunk(model=None, usage=None):
|
||||
@@ -132,6 +137,52 @@ class TestStreamingAccumulator:
|
||||
assert tc[0].function.name == "terminal"
|
||||
assert tc[0].function.arguments == '{"command": "ls"}'
|
||||
|
||||
@patch("run_agent.AIAgent._create_request_openai_client")
|
||||
@patch("run_agent.AIAgent._close_request_openai_client")
|
||||
def test_tool_call_extra_content_preserved(self, mock_close, mock_create):
|
||||
"""Streamed tool calls preserve provider-specific extra_content metadata."""
|
||||
from run_agent import AIAgent
|
||||
|
||||
chunks = [
|
||||
_make_stream_chunk(tool_calls=[
|
||||
_make_tool_call_delta(
|
||||
index=0,
|
||||
tc_id="call_gemini",
|
||||
name="cronjob",
|
||||
model_extra={
|
||||
"extra_content": {
|
||||
"google": {"thought_signature": "sig-123"}
|
||||
}
|
||||
},
|
||||
)
|
||||
]),
|
||||
_make_stream_chunk(tool_calls=[
|
||||
_make_tool_call_delta(index=0, arguments='{"task": "deep index on ."}')
|
||||
]),
|
||||
_make_stream_chunk(finish_reason="tool_calls"),
|
||||
]
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.chat.completions.create.return_value = iter(chunks)
|
||||
mock_create.return_value = mock_client
|
||||
|
||||
agent = AIAgent(
|
||||
model="test/model",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
agent.api_mode = "chat_completions"
|
||||
agent._interrupt_requested = False
|
||||
|
||||
response = agent._interruptible_streaming_api_call({})
|
||||
|
||||
tc = response.choices[0].message.tool_calls
|
||||
assert tc is not None
|
||||
assert tc[0].extra_content == {
|
||||
"google": {"thought_signature": "sig-123"}
|
||||
}
|
||||
|
||||
@patch("run_agent.AIAgent._create_request_openai_client")
|
||||
@patch("run_agent.AIAgent._close_request_openai_client")
|
||||
def test_mixed_content_and_tool_calls(self, mock_close, mock_create):
|
||||
@@ -436,6 +487,51 @@ class TestStreamingFallback:
|
||||
with pytest.raises(Exception, match="Rate limit exceeded"):
|
||||
agent._interruptible_streaming_api_call({})
|
||||
|
||||
@patch("run_agent.AIAgent._interruptible_api_call")
|
||||
@patch("run_agent.AIAgent._create_request_openai_client")
|
||||
@patch("run_agent.AIAgent._close_request_openai_client")
|
||||
def test_exhausted_transient_stream_error_falls_back(self, mock_close, mock_create, mock_non_stream):
|
||||
"""Transient stream errors retry first, then fall back after retries are exhausted."""
|
||||
from run_agent import AIAgent
|
||||
import httpx
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.chat.completions.create.side_effect = httpx.ConnectError("socket closed")
|
||||
mock_create.return_value = mock_client
|
||||
|
||||
fallback_response = SimpleNamespace(
|
||||
id="fallback",
|
||||
model="test",
|
||||
choices=[SimpleNamespace(
|
||||
index=0,
|
||||
message=SimpleNamespace(
|
||||
role="assistant",
|
||||
content="fallback after retries exhausted",
|
||||
tool_calls=None,
|
||||
reasoning_content=None,
|
||||
),
|
||||
finish_reason="stop",
|
||||
)],
|
||||
usage=None,
|
||||
)
|
||||
mock_non_stream.return_value = fallback_response
|
||||
|
||||
agent = AIAgent(
|
||||
model="test/model",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
agent.api_mode = "chat_completions"
|
||||
agent._interrupt_requested = False
|
||||
|
||||
response = agent._interruptible_streaming_api_call({})
|
||||
|
||||
assert response.choices[0].message.content == "fallback after retries exhausted"
|
||||
assert mock_client.chat.completions.create.call_count == 3
|
||||
mock_non_stream.assert_called_once()
|
||||
assert mock_close.call_count >= 1
|
||||
|
||||
|
||||
# ── Test: Reasoning Streaming ────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -2750,3 +2750,153 @@ class TestMCPSelectiveToolLoading:
|
||||
|
||||
assert connect_called == []
|
||||
assert result == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool name collision protection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRegistryCollisionWarning:
|
||||
"""registry.register() warns when a tool name is overwritten by a different toolset."""
|
||||
|
||||
def test_overwrite_different_toolset_logs_warning(self, caplog):
|
||||
"""Overwriting a tool from a different toolset emits a warning."""
|
||||
from tools.registry import ToolRegistry
|
||||
import logging
|
||||
|
||||
reg = ToolRegistry()
|
||||
schema = {"name": "my_tool", "description": "test", "parameters": {"type": "object", "properties": {}}}
|
||||
handler = lambda args, **kw: "{}"
|
||||
|
||||
reg.register(name="my_tool", toolset="builtin", schema=schema, handler=handler)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="tools.registry"):
|
||||
reg.register(name="my_tool", toolset="mcp-ext", schema=schema, handler=handler)
|
||||
|
||||
assert any("collision" in r.message.lower() for r in caplog.records)
|
||||
assert any("builtin" in r.message and "mcp-ext" in r.message for r in caplog.records)
|
||||
|
||||
def test_overwrite_same_toolset_no_warning(self, caplog):
|
||||
"""Re-registering within the same toolset is silent (e.g. reconnect)."""
|
||||
from tools.registry import ToolRegistry
|
||||
import logging
|
||||
|
||||
reg = ToolRegistry()
|
||||
schema = {"name": "my_tool", "description": "test", "parameters": {"type": "object", "properties": {}}}
|
||||
handler = lambda args, **kw: "{}"
|
||||
|
||||
reg.register(name="my_tool", toolset="mcp-server", schema=schema, handler=handler)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="tools.registry"):
|
||||
reg.register(name="my_tool", toolset="mcp-server", schema=schema, handler=handler)
|
||||
|
||||
assert not any("collision" in r.message.lower() for r in caplog.records)
|
||||
|
||||
|
||||
class TestMCPBuiltinCollisionGuard:
|
||||
"""MCP tools that collide with built-in tool names are skipped."""
|
||||
|
||||
def test_mcp_tool_skipped_when_builtin_exists(self):
|
||||
"""An MCP tool whose prefixed name collides with a built-in is skipped."""
|
||||
from tools.registry import ToolRegistry
|
||||
from tools.mcp_tool import _discover_and_register_server, _servers, MCPServerTask
|
||||
|
||||
mock_registry = ToolRegistry()
|
||||
|
||||
# Pre-register a "built-in" tool with the name that the MCP tool would produce.
|
||||
# Server "abc", tool "search" → mcp_abc_search
|
||||
builtin_schema = {
|
||||
"name": "mcp_abc_search",
|
||||
"description": "A hypothetical built-in",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
}
|
||||
mock_registry.register(
|
||||
name="mcp_abc_search", toolset="web",
|
||||
schema=builtin_schema, handler=lambda a, **k: "{}",
|
||||
)
|
||||
|
||||
mock_tools = [_make_mcp_tool("search", "Search the web")]
|
||||
mock_session = MagicMock()
|
||||
|
||||
async def fake_connect(name, config):
|
||||
server = MCPServerTask(name)
|
||||
server.session = mock_session
|
||||
server._tools = mock_tools
|
||||
return server
|
||||
|
||||
with patch("tools.mcp_tool._connect_server", side_effect=fake_connect), \
|
||||
patch("tools.registry.registry", mock_registry):
|
||||
registered = asyncio.run(
|
||||
_discover_and_register_server("abc", {"command": "test", "args": []})
|
||||
)
|
||||
|
||||
# The MCP tool should have been skipped — built-in preserved.
|
||||
assert "mcp_abc_search" not in registered
|
||||
assert mock_registry.get_toolset_for_tool("mcp_abc_search") == "web"
|
||||
|
||||
_servers.pop("abc", None)
|
||||
|
||||
def test_mcp_tool_registered_when_no_builtin_collision(self):
|
||||
"""MCP tools register normally when there's no collision."""
|
||||
from tools.registry import ToolRegistry
|
||||
from tools.mcp_tool import _discover_and_register_server, _servers, MCPServerTask
|
||||
|
||||
mock_registry = ToolRegistry()
|
||||
mock_tools = [_make_mcp_tool("web_search", "Search the web")]
|
||||
mock_session = MagicMock()
|
||||
|
||||
async def fake_connect(name, config):
|
||||
server = MCPServerTask(name)
|
||||
server.session = mock_session
|
||||
server._tools = mock_tools
|
||||
return server
|
||||
|
||||
with patch("tools.mcp_tool._connect_server", side_effect=fake_connect), \
|
||||
patch("tools.registry.registry", mock_registry):
|
||||
registered = asyncio.run(
|
||||
_discover_and_register_server("minimax", {"command": "test", "args": []})
|
||||
)
|
||||
|
||||
assert "mcp_minimax_web_search" in registered
|
||||
assert mock_registry.get_toolset_for_tool("mcp_minimax_web_search") == "mcp-minimax"
|
||||
|
||||
_servers.pop("minimax", None)
|
||||
|
||||
def test_mcp_tool_allowed_when_collision_is_another_mcp(self):
|
||||
"""Collision between two MCP toolsets is allowed (last wins)."""
|
||||
from tools.registry import ToolRegistry
|
||||
from tools.mcp_tool import _discover_and_register_server, _servers, MCPServerTask
|
||||
|
||||
mock_registry = ToolRegistry()
|
||||
|
||||
# Pre-register an MCP tool from a different server.
|
||||
mcp_schema = {
|
||||
"name": "mcp_srv_do_thing",
|
||||
"description": "From another MCP server",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
}
|
||||
mock_registry.register(
|
||||
name="mcp_srv_do_thing", toolset="mcp-old",
|
||||
schema=mcp_schema, handler=lambda a, **k: "{}",
|
||||
)
|
||||
|
||||
mock_tools = [_make_mcp_tool("do_thing", "Do a thing")]
|
||||
mock_session = MagicMock()
|
||||
|
||||
async def fake_connect(name, config):
|
||||
server = MCPServerTask(name)
|
||||
server.session = mock_session
|
||||
server._tools = mock_tools
|
||||
return server
|
||||
|
||||
with patch("tools.mcp_tool._connect_server", side_effect=fake_connect), \
|
||||
patch("tools.registry.registry", mock_registry):
|
||||
registered = asyncio.run(
|
||||
_discover_and_register_server("srv", {"command": "test", "args": []})
|
||||
)
|
||||
|
||||
# MCP-to-MCP collision is allowed — the new server wins.
|
||||
assert "mcp_srv_do_thing" in registered
|
||||
assert mock_registry.get_toolset_for_tool("mcp_srv_do_thing") == "mcp-srv"
|
||||
|
||||
_servers.pop("srv", None)
|
||||
|
||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from tools.skills_sync import (
|
||||
_get_bundled_dir,
|
||||
_read_manifest,
|
||||
_write_manifest,
|
||||
_discover_bundled_skills,
|
||||
@@ -467,3 +468,24 @@ class TestSyncSkills:
|
||||
new_bundled_hash = _dir_hash(bundled / "old-skill")
|
||||
assert manifest["old-skill"] == new_bundled_hash
|
||||
assert manifest["old-skill"] != old_hash
|
||||
|
||||
|
||||
class TestGetBundledDir:
|
||||
def test_env_var_override(self, tmp_path, monkeypatch):
|
||||
"""HERMES_BUNDLED_SKILLS env var overrides the default path resolution."""
|
||||
custom_dir = tmp_path / "custom_skills"
|
||||
custom_dir.mkdir()
|
||||
monkeypatch.setenv("HERMES_BUNDLED_SKILLS", str(custom_dir))
|
||||
assert _get_bundled_dir() == custom_dir
|
||||
|
||||
def test_default_without_env_var(self, monkeypatch):
|
||||
"""Without the env var, falls back to relative path from __file__."""
|
||||
monkeypatch.delenv("HERMES_BUNDLED_SKILLS", raising=False)
|
||||
result = _get_bundled_dir()
|
||||
assert result.name == "skills"
|
||||
|
||||
def test_env_var_empty_string_ignored(self, monkeypatch):
|
||||
"""Empty HERMES_BUNDLED_SKILLS should fall back to default."""
|
||||
monkeypatch.setenv("HERMES_BUNDLED_SKILLS", "")
|
||||
result = _get_bundled_dir()
|
||||
assert result.name == "skills"
|
||||
|
||||
@@ -292,6 +292,8 @@ def test_check_website_access_blocks_scheme_less_urls(tmp_path):
|
||||
def test_browser_navigate_returns_policy_block(monkeypatch):
|
||||
from tools import browser_tool
|
||||
|
||||
# Allow SSRF check to pass so the policy check is reached
|
||||
monkeypatch.setattr(browser_tool, "_is_safe_url", lambda url: True)
|
||||
monkeypatch.setattr(
|
||||
browser_tool,
|
||||
"check_website_access",
|
||||
|
||||
+24
-1
@@ -70,6 +70,11 @@ try:
|
||||
from tools.website_policy import check_website_access
|
||||
except Exception:
|
||||
check_website_access = lambda url: None # noqa: E731 — fail-open if policy module unavailable
|
||||
|
||||
try:
|
||||
from tools.url_safety import is_safe_url as _is_safe_url
|
||||
except Exception:
|
||||
_is_safe_url = lambda url: False # noqa: E731 — fail-closed: block all if safety module unavailable
|
||||
from tools.browser_providers.base import CloudBrowserProvider
|
||||
from tools.browser_providers.browserbase import BrowserbaseProvider
|
||||
from tools.browser_providers.browser_use import BrowserUseProvider
|
||||
@@ -1025,6 +1030,13 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:
|
||||
Returns:
|
||||
JSON string with navigation result (includes stealth features info on first nav)
|
||||
"""
|
||||
# SSRF protection — block private/internal addresses before navigating
|
||||
if not _is_safe_url(url):
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": "Blocked: URL targets a private or internal address",
|
||||
})
|
||||
|
||||
# Website policy check — block before navigating
|
||||
blocked = check_website_access(url)
|
||||
if blocked:
|
||||
@@ -1052,7 +1064,18 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:
|
||||
data = result.get("data", {})
|
||||
title = data.get("title", "")
|
||||
final_url = data.get("url", url)
|
||||
|
||||
|
||||
# Post-redirect SSRF check — if the browser followed a redirect to a
|
||||
# private/internal address, block the result so the model can't read
|
||||
# internal content via subsequent browser_snapshot calls.
|
||||
if final_url and final_url != url and not _is_safe_url(final_url):
|
||||
# Navigate away to a blank page to prevent snapshot leaks
|
||||
_run_browser_command(effective_task_id, "open", ["about:blank"], timeout=10)
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": "Blocked: redirect landed on a private/internal address",
|
||||
})
|
||||
|
||||
response = {
|
||||
"success": True,
|
||||
"url": final_url,
|
||||
|
||||
@@ -23,8 +23,8 @@ import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from hermes_constants import get_hermes_home
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -33,7 +33,7 @@ logger = logging.getLogger(__name__)
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CHECKPOINT_BASE = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) / "checkpoints"
|
||||
CHECKPOINT_BASE = get_hermes_home() / "checkpoints"
|
||||
|
||||
DEFAULT_EXCLUDES = [
|
||||
"node_modules/",
|
||||
@@ -505,7 +505,7 @@ class CheckpointManager:
|
||||
# Get the hash of the commit at the cutoff point
|
||||
ok, cutoff_hash, _ = _run_git(
|
||||
["rev-list", "--reverse", "HEAD", "--skip=0",
|
||||
f"--max-count=1"],
|
||||
"--max-count=1"],
|
||||
shadow_repo, working_dir,
|
||||
)
|
||||
|
||||
@@ -542,7 +542,7 @@ def format_checkpoint_list(checkpoints: List[Dict], directory: str) -> str:
|
||||
|
||||
lines.append(f" {i}. {cp['short_hash']} {ts} {cp['reason']}{stat}")
|
||||
|
||||
lines.append(f"\n /rollback <N> restore to checkpoint N")
|
||||
lines.append(f" /rollback diff <N> preview changes since checkpoint N")
|
||||
lines.append(f" /rollback <N> <file> restore a single file from checkpoint N")
|
||||
lines.append("\n /rollback <N> restore to checkpoint N")
|
||||
lines.append(" /rollback diff <N> preview changes since checkpoint N")
|
||||
lines.append(" /rollback <N> <file> restore a single file from checkpoint N")
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -12,7 +12,7 @@ a thin dispatcher that delegates to a platform-provided callback.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Dict, Any, List, Optional, Callable
|
||||
from typing import List, Optional, Callable
|
||||
|
||||
|
||||
# Maximum number of predefined choices the agent can offer.
|
||||
|
||||
@@ -171,7 +171,6 @@ def _build_child_agent(
|
||||
model on OpenRouter while the parent runs on Nous Portal).
|
||||
"""
|
||||
from run_agent import AIAgent
|
||||
import model_tools
|
||||
|
||||
# When no explicit toolsets given, inherit from parent's enabled toolsets
|
||||
# so disabled tools (e.g. web) don't leak to subagents.
|
||||
@@ -191,9 +190,10 @@ def _build_child_agent(
|
||||
# Build progress callback to relay tool calls to parent display
|
||||
child_progress_cb = _build_child_progress_callback(task_index, parent_agent)
|
||||
|
||||
# Share the parent's iteration budget so subagent tool calls
|
||||
# count toward the session-wide limit.
|
||||
shared_budget = getattr(parent_agent, "iteration_budget", None)
|
||||
# Each subagent gets its own iteration budget capped at max_iterations
|
||||
# (configurable via delegation.max_iterations, default 50). This means
|
||||
# total iterations across parent + subagents can exceed the parent's
|
||||
# max_iterations. The user controls the per-subagent cap in config.yaml.
|
||||
|
||||
# Resolve effective credentials: config override > parent inherit
|
||||
effective_model = model or parent_agent.model
|
||||
@@ -230,7 +230,7 @@ def _build_child_agent(
|
||||
providers_order=parent_agent.providers_order,
|
||||
provider_sort=parent_agent.provider_sort,
|
||||
tool_progress_callback=child_progress_cb,
|
||||
iteration_budget=shared_budget,
|
||||
iteration_budget=None, # fresh budget per subagent
|
||||
)
|
||||
# Set delegation depth so children can't spawn grandchildren
|
||||
child._delegate_depth = getattr(parent_agent, '_delegate_depth', 0) + 1
|
||||
|
||||
@@ -40,7 +40,8 @@ class PersistentShellMixin:
|
||||
def _cleanup_temp_files(self): ...
|
||||
|
||||
_session_id: str = ""
|
||||
_poll_interval: float = 0.01
|
||||
_poll_interval_start: float = 0.01 # initial poll interval (10ms)
|
||||
_poll_interval_max: float = 0.25 # max poll interval (250ms) — reduces I/O for long commands
|
||||
|
||||
@property
|
||||
def _temp_prefix(self) -> str:
|
||||
@@ -224,7 +225,7 @@ class PersistentShellMixin:
|
||||
)
|
||||
self._send_to_shell(ipc_script)
|
||||
deadline = time.monotonic() + timeout
|
||||
poll_interval = self._poll_interval
|
||||
poll_interval = self._poll_interval_start # starts at 10ms, backs off to 250ms
|
||||
|
||||
while True:
|
||||
if is_interrupted():
|
||||
@@ -256,6 +257,10 @@ class PersistentShellMixin:
|
||||
break
|
||||
|
||||
time.sleep(poll_interval)
|
||||
# Exponential backoff: fast start (10ms) for quick commands,
|
||||
# ramps up to 250ms for long-running commands — reduces I/O by 10-25x
|
||||
# on WSL2 where polling keeps the VM hot and memory pressure high.
|
||||
poll_interval = min(poll_interval * 1.5, self._poll_interval_max)
|
||||
|
||||
output, exit_code, new_cwd = self._read_persistent_output()
|
||||
if new_cwd:
|
||||
|
||||
@@ -87,7 +87,7 @@ class SSHEnvironment(PersistentShellMixin, BaseEnvironment):
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError(f"SSH connection to {self.user}@{self.host} timed out")
|
||||
|
||||
_poll_interval: float = 0.15
|
||||
_poll_interval_start: float = 0.15 # SSH: higher initial interval (150ms) for network latency
|
||||
|
||||
@property
|
||||
def _temp_prefix(self) -> str:
|
||||
|
||||
@@ -27,11 +27,10 @@ Usage:
|
||||
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import difflib
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
from typing import Optional, List, Dict, Any
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user