6.6 KiB
6.6 KiB
| name | version | description | author | tags | |||||
|---|---|---|---|---|---|---|---|---|---|
| hermes-web-cli-main-architecture | 1.0 | Server-side architecture patterns for hermes-web-cli main.py — subprocess communication, session persistence, dynamic path resolution, and config handling. | Hermes Agent |
|
Hermes Web CLI Main.py Architecture Patterns
Overview
This skill documents the server-side architecture patterns for main.py in hermes-web-cli modules. Covers subprocess communication with hermes CLI, session persistence, dynamic path resolution, and configuration defaults.
1. Subprocess Communication with Hermes CLI
Pattern: Non-interactive CLI invocation
When calling the hermes CLI from the web server via subprocess, you MUST use non-interactive mode to get a single response without polluting user session lists.
cmd = [
python_path, "-m", "hermes",
"chat", "-q", request.message,
"--source", "tool"
]
Critical flags:
-q(quiet): Returns a single response instead of entering interactive chat mode--source tool: Marks the message as coming from a tool integration, not a user chat session
Why this matters:
- Without
-q, the subprocess enters interactive mode and never returns - Without
--source tool, messages appear in the user's session history, confusing the session list
Working subprocess pattern:
result = subprocess.run(
cmd,
cwd=BASE_HERMES_PATH,
capture_output=True,
text=True,
timeout=120,
env={**os.environ, "PYTHONIOENCODING": "utf-8"}
)
response_text = result.stdout.strip()
API Endpoints using subprocess:
POST /api/sessions/{session_id}/messages— send message via hermes CLIPOST /api/services/test— test service connection via hermes CLI
2. Session Persistence Pattern
Problem: In-memory dict loses data on server restart
The global_sessions dict is used for session management but is lost on restart.
Solution: JSON file persistence with threading lock
import threading
import json
import os
# In-memory cache
global_sessions = {}
SESSIONS_FILE = os.path.join(os.path.dirname(__file__), "data", "sessions.json")
sessions_lock = threading.Lock()
def load_sessions():
"""Load sessions from JSON file on startup."""
global global_sessions
if os.path.exists(SESSIONS_FILE):
try:
with open(SESSIONS_FILE, 'r') as f:
global_sessions = json.load(f)
except Exception as e:
print(f"Warning: Could not load sessions file: {e}")
def save_sessions():
"""Save sessions to JSON file (call after modifications)."""
os.makedirs(os.path.dirname(SESSIONS_FILE), exist_ok=True)
with open(SESSIONS_FILE, 'w') as f:
json.dump(global_sessions, f, indent=2, default=str)
# Load on startup
load_sessions()
# Use lock for thread-safe modifications
with sessions_lock:
global_sessions[session_id] = session_data
save_sessions()
Key rules:
- Always wrap modifications in
with sessions_lock: - Call
save_sessions()immediately after modifyingglobal_sessions - Use
default=strin json.dump to handle datetime objects - Create data directory with
exist_ok=True
3. Dynamic Path Resolution
Problem: Hardcoded paths break across environments
Solution: Multi-level fallback path resolution
def _resolve_hermes_home() -> str:
"""Find the hermes home directory using multiple strategies."""
# 1. Environment variable (highest priority)
hermes_home = os.environ.get("HERMES_HOME")
if hermes_home and os.path.exists(hermes_home):
return hermes_home
# 2. Use the official function
try:
from hermes.config import get_hermes_home
home = get_hermes_home()
if home and os.path.exists(home):
return home
except ImportError:
pass
# 3. Relative to current file
current_dir = os.path.dirname(os.path.abspath(__file__))
candidate = os.path.abspath(os.path.join(current_dir, "..", ".."))
if os.path.exists(os.path.join(candidate, "hermes")):
return candidate
# 4. Fallback to default
return os.path.expanduser("~/.hermes")
BASE_HERMES_PATH = _resolve_hermes_home()
Python interpreter path:
# Dynamic venv python path
PYTHON_PATH = os.path.join(BASE_HERMES_PATH, ".venv", "bin", "python3")
if not os.path.exists(PYTHON_PATH):
PYTHON_PATH = "python3" # Fallback to system python
4. Configuration Default Values
Problem: Missing keys in config.yaml cause KeyError
Solution: Provide complete defaults with merge logic
DEFAULT_CONFIG = {
"hermes_web_cli": {
"enabled": True,
"hermes_path": "", # Empty = auto-detect
"auth_method": "header", # 'header' or 'bearer'
"api_key": "",
"allowed_ips": [],
"rate_limit": 100,
}
}
def get_config() -> dict:
config = copy.deepcopy(DEFAULT_CONFIG)
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, 'r') as f:
user_config = yaml.safe_load(f) or {}
# Deep merge user config over defaults
deep_merge(config, user_config)
return config
Critical defaults that prevent errors:
auth_method: Must default to'header'to prevent KeyError in auth checks- All list/dict fields should default to empty
[]or{}
5. User ID Propagation
Pattern: Thread-local user context for subprocess calls
When the web server handles authenticated requests, the user ID must be propagated to subprocess calls:
# In request handler
user_id = request.headers.get("X-User-Id", "anonymous")
# Set as environment variable for subprocess
env = {**os.environ, "HERMES_USER_ID": user_id}
result = subprocess.run(cmd, env=env, ...)
Common Pitfalls
- Missing -q flag: Causes subprocess to hang in interactive mode
- Missing --source tool: Pollutes user's session list with tool-generated messages
- No threading lock: Race conditions on global_sessions cause data corruption
- Hardcoded paths: Breaks when hermes is installed in non-standard locations
- Missing config defaults: KeyError when config.yaml is incomplete
- No session file directory creation: FileNotFoundError on first save
Verification Checklist
- All subprocess calls use
-q --source toolflags - Session modifications are wrapped in
with sessions_lock: save_sessions()called after every modification- Path resolution uses multi-level fallback
- Config defaults include
auth_method: "header" - Data directory created with
exist_ok=True - JSON dump uses
default=strfor datetime handling