Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c899f8a71b | |||
| 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
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:,}"
|
||||
|
||||
@@ -458,13 +458,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 +1049,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).
|
||||
@@ -1186,8 +1183,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 +1475,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 +1590,7 @@ class HermesCLI:
|
||||
"""
|
||||
if not text:
|
||||
return
|
||||
self._reasoning_stream_started = True
|
||||
if getattr(self, "_stream_box_opened", False):
|
||||
return
|
||||
|
||||
@@ -1691,11 +1786,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 +1949,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 +2023,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,
|
||||
@@ -2243,7 +2336,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.")
|
||||
@@ -2443,7 +2536,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 +2876,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
|
||||
@@ -3931,7 +4024,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 +4067,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 +4129,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"
|
||||
@@ -4235,11 +4338,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 +4385,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 +4393,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 +4416,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."""
|
||||
@@ -4538,7 +4630,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 +4950,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
|
||||
@@ -5628,7 +5719,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
|
||||
@@ -6432,8 +6523,7 @@ class HermesCLI:
|
||||
"""Return provider/model info for /model autocomplete."""
|
||||
try:
|
||||
from hermes_cli.models import (
|
||||
_PROVIDER_LABELS, _PROVIDER_MODELS, normalize_provider,
|
||||
provider_model_ids,
|
||||
_PROVIDER_LABELS, normalize_provider, provider_model_ids,
|
||||
)
|
||||
current = getattr(cli_ref, "provider", None) or getattr(cli_ref, "requested_provider", "openrouter")
|
||||
current = normalize_provider(current)
|
||||
@@ -7119,7 +7209,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:
|
||||
|
||||
+6
-2
@@ -24,7 +24,6 @@ except ImportError:
|
||||
import msvcrt
|
||||
except ImportError:
|
||||
msvcrt = None
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -280,6 +279,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])
|
||||
@@ -411,7 +411,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 +476,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
|
||||
|
||||
|
||||
+1
-3
@@ -220,7 +220,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__)
|
||||
@@ -2771,8 +2771,6 @@ class GatewayRunner:
|
||||
"""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,
|
||||
|
||||
+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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,7 +11,7 @@ import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Any, Optional
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
@@ -257,7 +257,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
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -46,6 +46,32 @@ 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 = Path(os.getenv("HERMES_HOME", str(Path.home() / ".hermes"))) / ".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
|
||||
# =============================================================================
|
||||
@@ -317,6 +343,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 +1368,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 +1512,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 +1771,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 +1803,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)")
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
+10
-1
@@ -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,
|
||||
@@ -1562,6 +1562,9 @@ def _setup_signal():
|
||||
|
||||
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)
|
||||
|
||||
+4
-8
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-7
@@ -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
|
||||
@@ -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()
|
||||
@@ -3299,7 +3298,6 @@ def _run_quick_setup(config: dict, hermes_home):
|
||||
get_missing_env_vars,
|
||||
get_missing_config_fields,
|
||||
check_config_version,
|
||||
migrate_config,
|
||||
)
|
||||
|
||||
print()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -7,11 +7,9 @@ Provides options for:
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ 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 typing import Optional
|
||||
|
||||
|
||||
+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" ];
|
||||
}
|
||||
@@ -53,8 +53,7 @@ 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
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
+66
-32
@@ -65,7 +65,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 +161,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.
|
||||
@@ -887,7 +890,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
|
||||
@@ -1321,6 +1325,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 +1568,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]:
|
||||
"""
|
||||
@@ -3656,6 +3678,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 +3688,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 +3719,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 +3823,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,30 +3842,34 @@ 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")
|
||||
@@ -4687,7 +4720,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
|
||||
@@ -5709,7 +5742,7 @@ class AIAgent:
|
||||
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)
|
||||
@@ -6378,6 +6411,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
|
||||
@@ -7168,7 +7202,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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
[
|
||||
|
||||
+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 ────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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": f"Blocked: redirect landed on a private/internal address",
|
||||
})
|
||||
|
||||
response = {
|
||||
"success": True,
|
||||
"url": final_url,
|
||||
|
||||
@@ -23,7 +23,6 @@ import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
+2
-4
@@ -4,9 +4,7 @@
|
||||
import errno
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from typing import Optional
|
||||
from tools.file_operations import ShellFileOperations
|
||||
from agent.redact import redact_sensitive_text
|
||||
|
||||
@@ -50,8 +48,8 @@ def _get_file_ops(task_id: str = "default") -> ShellFileOperations:
|
||||
from tools.terminal_tool import (
|
||||
_active_environments, _env_lock, _create_environment,
|
||||
_get_env_config, _last_activity, _start_cleanup_thread,
|
||||
_check_disk_usage_warning,
|
||||
_creation_locks, _creation_locks_lock,
|
||||
_creation_locks,
|
||||
_creation_locks_lock,
|
||||
)
|
||||
import time
|
||||
|
||||
|
||||
@@ -416,7 +416,7 @@ def check_image_generation_requirements() -> bool:
|
||||
return False
|
||||
|
||||
# Check if fal_client is available
|
||||
import fal_client
|
||||
import fal_client # noqa: F401 — SDK presence check
|
||||
return True
|
||||
|
||||
except ImportError:
|
||||
|
||||
@@ -34,7 +34,6 @@ import logging
|
||||
import os
|
||||
import platform
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
@@ -44,7 +43,6 @@ import uuid
|
||||
_IS_WINDOWS = platform.system() == "Windows"
|
||||
from tools.environments.local import _find_shell, _sanitize_subprocess_env
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ Import chain (circular-import safe):
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Callable, Dict, List, Optional, Set
|
||||
from typing import Callable, Dict, List, Optional, Set
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ import uuid
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import yaml
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
@@ -379,7 +379,7 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No
|
||||
else:
|
||||
# Reuse the gateway adapter's format_message for markdown→MarkdownV2
|
||||
try:
|
||||
from gateway.platforms.telegram import TelegramAdapter, _escape_mdv2, _strip_mdv2
|
||||
from gateway.platforms.telegram import TelegramAdapter, _strip_mdv2
|
||||
_adapter = TelegramAdapter.__new__(TelegramAdapter)
|
||||
formatted = _adapter.format_message(message)
|
||||
except Exception:
|
||||
|
||||
@@ -18,7 +18,6 @@ Flow:
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ import httpx
|
||||
import yaml
|
||||
|
||||
from tools.skills_guard import (
|
||||
ScanResult, scan_skill, should_allow_install, content_hash, TRUSTED_REPOS,
|
||||
ScanResult, content_hash, TRUSTED_REPOS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -37,7 +37,14 @@ MANIFEST_FILE = SKILLS_DIR / ".bundled_manifest"
|
||||
|
||||
|
||||
def _get_bundled_dir() -> Path:
|
||||
"""Locate the bundled skills/ directory in the repo."""
|
||||
"""Locate the bundled skills/ directory.
|
||||
|
||||
Checks HERMES_BUNDLED_SKILLS env var first (set by Nix wrapper),
|
||||
then falls back to the relative path from this source file.
|
||||
"""
|
||||
env_override = os.getenv("HERMES_BUNDLED_SKILLS")
|
||||
if env_override:
|
||||
return Path(env_override)
|
||||
return Path(__file__).parent.parent / "skills"
|
||||
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
import atexit
|
||||
@@ -48,7 +47,7 @@ logger = logging.getLogger(__name__)
|
||||
# The terminal tool polls this during command execution so it can kill
|
||||
# long-running subprocesses immediately instead of blocking until timeout.
|
||||
# ---------------------------------------------------------------------------
|
||||
from tools.interrupt import is_interrupted, _interrupt_event
|
||||
from tools.interrupt import is_interrupted, _interrupt_event # noqa: F401 — re-exported
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -1232,7 +1231,7 @@ def check_terminal_requirements() -> bool:
|
||||
return True
|
||||
|
||||
elif env_type == "daytona":
|
||||
from daytona import Daytona
|
||||
from daytona import Daytona # noqa: F401 — SDK presence check
|
||||
return os.getenv("DAYTONA_API_KEY") is not None
|
||||
|
||||
else:
|
||||
|
||||
@@ -629,7 +629,6 @@ def stream_tts_to_speaker(
|
||||
if client is not None:
|
||||
try:
|
||||
sd = _import_sounddevice()
|
||||
import numpy as _np
|
||||
output_stream = sd.OutputStream(
|
||||
samplerate=24000, channels=1, dtype="int16",
|
||||
)
|
||||
|
||||
@@ -28,7 +28,6 @@ Usage:
|
||||
)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
|
||||
+1
-2
@@ -19,7 +19,6 @@ import tempfile
|
||||
import threading
|
||||
import time
|
||||
import wave
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -710,7 +709,7 @@ def check_voice_requirements() -> Dict[str, Any]:
|
||||
``missing_packages``, and ``details``.
|
||||
"""
|
||||
# Determine STT provider availability
|
||||
from tools.transcription_tools import _get_provider, _load_stt_config, is_stt_enabled, _HAS_FASTER_WHISPER
|
||||
from tools.transcription_tools import _get_provider, _load_stt_config, is_stt_enabled
|
||||
stt_config = _load_stt_config()
|
||||
stt_enabled = is_stt_enabled(stt_config)
|
||||
stt_provider = _get_provider(stt_config)
|
||||
|
||||
@@ -59,6 +59,10 @@ The only prerequisite is **Git**. The installer automatically handles everything
|
||||
You do **not** need to install Python, Node.js, ripgrep, or ffmpeg manually. The installer detects what's missing and installs it for you. Just make sure `git` is available (`git --version`).
|
||||
:::
|
||||
|
||||
:::tip Nix users
|
||||
If you use Nix (on NixOS, macOS, or Linux), there's a dedicated setup path with a Nix flake, declarative NixOS module, and optional container mode. See the **[Nix & NixOS Setup](./nix-setup.md)** guide.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## Manual Installation
|
||||
|
||||
@@ -0,0 +1,820 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
title: "Nix & NixOS Setup"
|
||||
description: "Install and deploy Hermes Agent with Nix — from quick `nix run` to fully declarative NixOS module with container mode"
|
||||
---
|
||||
|
||||
# Nix & NixOS Setup
|
||||
|
||||
Hermes Agent ships a Nix flake with three levels of integration:
|
||||
|
||||
| Level | Who it's for | What you get |
|
||||
|-------|-------------|--------------|
|
||||
| **`nix run` / `nix profile install`** | Any Nix user (macOS, Linux) | Pre-built binary with all deps — then use the standard CLI workflow |
|
||||
| **NixOS module (native)** | NixOS server deployments | Declarative config, hardened systemd service, managed secrets |
|
||||
| **NixOS module (container)** | Agents that need self-modification | Everything above, plus a persistent Ubuntu container where the agent can `apt`/`pip`/`npm install` |
|
||||
|
||||
:::info What's different from the standard install
|
||||
The `curl | bash` installer manages Python, Node, and dependencies itself. The Nix flake replaces all of that — every Python dependency is a Nix derivation built by [uv2nix](https://github.com/pyproject-nix/uv2nix), and runtime tools (Node.js, git, ripgrep, ffmpeg) are wrapped into the binary's PATH. There is no runtime pip, no venv activation, no `npm install`.
|
||||
|
||||
**For non-NixOS users**, this only changes the install step. Everything after (`hermes setup`, `hermes gateway install`, config editing) works identically to the standard install.
|
||||
|
||||
**For NixOS module users**, the entire lifecycle is different: configuration lives in `configuration.nix`, secrets go through sops-nix/agenix, the service is a systemd unit, and CLI config commands are blocked. You manage hermes the same way you manage any other NixOS service.
|
||||
:::
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Nix with flakes enabled** — [Determinate Nix](https://install.determinate.systems) recommended (enables flakes by default)
|
||||
- **API keys** for the services you want to use (at minimum: an OpenRouter or Anthropic key)
|
||||
|
||||
---
|
||||
|
||||
## Quick Start (Any Nix User)
|
||||
|
||||
No clone needed. Nix fetches, builds, and runs everything:
|
||||
|
||||
```bash
|
||||
# Run directly (builds on first use, cached after)
|
||||
nix run github:NousResearch/hermes-agent -- setup
|
||||
nix run github:NousResearch/hermes-agent -- chat
|
||||
|
||||
# Or install persistently
|
||||
nix profile install github:NousResearch/hermes-agent
|
||||
hermes setup
|
||||
hermes chat
|
||||
```
|
||||
|
||||
After `nix profile install`, `hermes`, `hermes-agent`, and `hermes-acp` are on your PATH. From here, the workflow is identical to the [standard installation](./installation.md) — `hermes setup` walks you through provider selection, `hermes gateway install` sets up a launchd (macOS) or systemd user service, and config lives in `~/.hermes/`.
|
||||
|
||||
<details>
|
||||
<summary><strong>Building from a local clone</strong></summary>
|
||||
|
||||
```bash
|
||||
git clone https://github.com/NousResearch/hermes-agent.git
|
||||
cd hermes-agent
|
||||
nix build
|
||||
./result/bin/hermes setup
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## NixOS Module
|
||||
|
||||
The flake exports `nixosModules.default` — a full NixOS service module that declaratively manages user creation, directories, config generation, secrets, documents, and service lifecycle.
|
||||
|
||||
:::note
|
||||
This module requires NixOS. For non-NixOS systems (macOS, other Linux distros), use `nix profile install` and the standard CLI workflow above.
|
||||
:::
|
||||
|
||||
### Add the Flake Input
|
||||
|
||||
```nix
|
||||
# /etc/nixos/flake.nix (or your system flake)
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
|
||||
hermes-agent.url = "github:NousResearch/hermes-agent";
|
||||
};
|
||||
|
||||
outputs = { nixpkgs, hermes-agent, ... }: {
|
||||
nixosConfigurations.your-host = nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
modules = [
|
||||
hermes-agent.nixosModules.default
|
||||
./configuration.nix
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Minimal Configuration
|
||||
|
||||
```nix
|
||||
# configuration.nix
|
||||
{ config, ... }: {
|
||||
services.hermes-agent = {
|
||||
enable = true;
|
||||
settings.model.default = "anthropic/claude-sonnet-4";
|
||||
environmentFiles = [ config.sops.secrets."hermes-env".path ];
|
||||
addToSystemPackages = true;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
That's it. `nixos-rebuild switch` creates the `hermes` user, generates `config.yaml`, wires up secrets, and starts the gateway — a long-running service that connects the agent to messaging platforms (Telegram, Discord, etc.) and listens for incoming messages.
|
||||
|
||||
:::warning Secrets are required
|
||||
The `environmentFiles` line above assumes you have [sops-nix](https://github.com/Mic92/sops-nix) or [agenix](https://github.com/ryantm/agenix) configured. The file should contain at least one LLM provider key (e.g., `OPENROUTER_API_KEY=sk-or-...`). See [Secrets Management](#secrets-management) for full setup. If you don't have a secrets manager yet, you can use a plain file as a starting point — just ensure it's not world-readable:
|
||||
|
||||
```bash
|
||||
echo "OPENROUTER_API_KEY=sk-or-your-key" | sudo install -m 0600 -o hermes /dev/stdin /var/lib/hermes/env
|
||||
```
|
||||
|
||||
```nix
|
||||
services.hermes-agent.environmentFiles = [ "/var/lib/hermes/env" ];
|
||||
```
|
||||
:::
|
||||
|
||||
:::tip addToSystemPackages
|
||||
Setting `addToSystemPackages = true` does two things: puts the `hermes` CLI on your system PATH **and** sets `HERMES_HOME` system-wide so the interactive CLI shares state (sessions, skills, cron) with the gateway service. Without it, running `hermes` in your shell creates a separate `~/.hermes/` directory.
|
||||
:::
|
||||
|
||||
### Verify It Works
|
||||
|
||||
After `nixos-rebuild switch`, check that the service is running:
|
||||
|
||||
```bash
|
||||
# Check service status
|
||||
systemctl status hermes-agent
|
||||
|
||||
# Watch logs (Ctrl+C to stop)
|
||||
journalctl -u hermes-agent -f
|
||||
|
||||
# If addToSystemPackages is true, test the CLI
|
||||
hermes version
|
||||
hermes config # shows the generated config
|
||||
```
|
||||
|
||||
### Choosing a Deployment Mode
|
||||
|
||||
The module supports two modes, controlled by `container.enable`:
|
||||
|
||||
| | **Native** (default) | **Container** |
|
||||
|---|---|---|
|
||||
| How it runs | Hardened systemd service on the host | Persistent Ubuntu container with `/nix/store` bind-mounted |
|
||||
| Security | `NoNewPrivileges`, `ProtectSystem=strict`, `PrivateTmp` | Container isolation, runs as unprivileged user inside |
|
||||
| Agent can self-install packages | No — only tools on the Nix-provided PATH | Yes — `apt`, `pip`, `npm` installs persist across restarts |
|
||||
| Config surface | Same | Same |
|
||||
| When to choose | Standard deployments, maximum security, reproducibility | Agent needs runtime package installation, mutable environment, experimental tools |
|
||||
|
||||
To enable container mode, add one line:
|
||||
|
||||
```nix
|
||||
{
|
||||
services.hermes-agent = {
|
||||
enable = true;
|
||||
container.enable = true;
|
||||
# ... rest of config is identical
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
:::info
|
||||
Container mode auto-enables `virtualisation.docker.enable` via `mkDefault`. If you use Podman instead, set `container.backend = "podman"` and `virtualisation.docker.enable = false`.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Declarative Settings
|
||||
|
||||
The `settings` option accepts an arbitrary attrset that is rendered as `config.yaml`. It supports deep merging across multiple module definitions (via `lib.recursiveUpdate`), so you can split config across files:
|
||||
|
||||
```nix
|
||||
# base.nix
|
||||
services.hermes-agent.settings = {
|
||||
model.default = "anthropic/claude-sonnet-4";
|
||||
toolsets = [ "all" ];
|
||||
terminal = { backend = "local"; timeout = 180; };
|
||||
};
|
||||
|
||||
# personality.nix
|
||||
services.hermes-agent.settings = {
|
||||
display = { compact = false; personality = "kawaii"; };
|
||||
memory = { memory_enabled = true; user_profile_enabled = true; };
|
||||
};
|
||||
```
|
||||
|
||||
Both are deep-merged at evaluation time. Nix-declared keys always win over keys in an existing `config.yaml` on disk, but **user-added keys that Nix doesn't touch are preserved**. This means if the agent or a manual edit adds keys like `skills.disabled` or `streaming.enabled`, they survive `nixos-rebuild switch`.
|
||||
|
||||
:::note Model naming
|
||||
`settings.model.default` uses the model identifier your provider expects. With [OpenRouter](https://openrouter.ai) (the default), these look like `"anthropic/claude-sonnet-4"` or `"google/gemini-3-flash"`. If you're using a provider directly (Anthropic, OpenAI), set `settings.model.base_url` to point at their API and use their native model IDs (e.g., `"claude-sonnet-4-20250514"`). When no `base_url` is set, Hermes defaults to OpenRouter.
|
||||
:::
|
||||
|
||||
:::tip Discovering available config keys
|
||||
Run `nix build .#configKeys && cat result` to see every leaf config key extracted from Python's `DEFAULT_CONFIG`. You can paste your existing `config.yaml` into the `settings` attrset — the structure maps 1:1.
|
||||
:::
|
||||
|
||||
<details>
|
||||
<summary><strong>Full example: all commonly customized settings</strong></summary>
|
||||
|
||||
```nix
|
||||
{ config, ... }: {
|
||||
services.hermes-agent = {
|
||||
enable = true;
|
||||
container.enable = true;
|
||||
|
||||
# ── Model ──────────────────────────────────────────────────────────
|
||||
settings = {
|
||||
model = {
|
||||
base_url = "https://openrouter.ai/api/v1";
|
||||
default = "anthropic/claude-opus-4.6";
|
||||
};
|
||||
toolsets = [ "all" ];
|
||||
max_turns = 100;
|
||||
terminal = { backend = "local"; cwd = "."; timeout = 180; };
|
||||
compression = {
|
||||
enabled = true;
|
||||
threshold = 0.85;
|
||||
summary_model = "google/gemini-3-flash-preview";
|
||||
};
|
||||
memory = { memory_enabled = true; user_profile_enabled = true; };
|
||||
display = { compact = false; personality = "kawaii"; };
|
||||
agent = { max_turns = 60; verbose = false; };
|
||||
};
|
||||
|
||||
# ── Secrets ────────────────────────────────────────────────────────
|
||||
environmentFiles = [ config.sops.secrets."hermes-env".path ];
|
||||
|
||||
# ── Documents ──────────────────────────────────────────────────────
|
||||
documents = {
|
||||
"SOUL.md" = builtins.readFile /home/user/.hermes/SOUL.md;
|
||||
"USER.md" = ./documents/USER.md;
|
||||
};
|
||||
|
||||
# ── MCP Servers ────────────────────────────────────────────────────
|
||||
mcpServers.filesystem = {
|
||||
command = "npx";
|
||||
args = [ "-y" "@modelcontextprotocol/server-filesystem" "/data/workspace" ];
|
||||
};
|
||||
|
||||
# ── Container options ──────────────────────────────────────────────
|
||||
container = {
|
||||
image = "ubuntu:24.04";
|
||||
backend = "docker";
|
||||
extraVolumes = [ "/home/user/projects:/projects:rw" ];
|
||||
extraOptions = [ "--gpus" "all" ];
|
||||
};
|
||||
|
||||
# ── Service tuning ─────────────────────────────────────────────────
|
||||
addToSystemPackages = true;
|
||||
extraArgs = [ "--verbose" ];
|
||||
restart = "always";
|
||||
restartSec = 5;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Escape Hatch: Bring Your Own Config
|
||||
|
||||
If you'd rather manage `config.yaml` entirely outside Nix, use `configFile`:
|
||||
|
||||
```nix
|
||||
services.hermes-agent.configFile = /etc/hermes/config.yaml;
|
||||
```
|
||||
|
||||
This bypasses `settings` entirely — no merge, no generation. The file is copied as-is to `$HERMES_HOME/config.yaml` on each activation.
|
||||
|
||||
### Customization Cheatsheet
|
||||
|
||||
Quick reference for the most common things Nix users want to customize:
|
||||
|
||||
| I want to... | Option | Example |
|
||||
|---|---|---|
|
||||
| Change the LLM model | `settings.model.default` | `"anthropic/claude-sonnet-4"` |
|
||||
| Use a different provider endpoint | `settings.model.base_url` | `"https://openrouter.ai/api/v1"` |
|
||||
| Add API keys | `environmentFiles` | `[ config.sops.secrets."hermes-env".path ]` |
|
||||
| Give the agent a personality | `documents."SOUL.md"` | `builtins.readFile ./my-soul.md` |
|
||||
| Add MCP tool servers | `mcpServers.<name>` | See [MCP Servers](#mcp-servers) |
|
||||
| Mount host directories into container | `container.extraVolumes` | `[ "/data:/data:rw" ]` |
|
||||
| Pass GPU access to container | `container.extraOptions` | `[ "--gpus" "all" ]` |
|
||||
| Use Podman instead of Docker | `container.backend` | `"podman"` |
|
||||
| Add tools to the service PATH (native only) | `extraPackages` | `[ pkgs.pandoc pkgs.imagemagick ]` |
|
||||
| Use a custom base image | `container.image` | `"ubuntu:24.04"` |
|
||||
| Override the hermes package | `package` | `inputs.hermes-agent.packages.${system}.default.override { ... }` |
|
||||
| Change state directory | `stateDir` | `"/opt/hermes"` |
|
||||
| Set the agent's working directory | `workingDirectory` | `"/home/user/projects"` |
|
||||
|
||||
---
|
||||
|
||||
## Secrets Management
|
||||
|
||||
:::danger Never put API keys in `settings` or `environment`
|
||||
Values in Nix expressions end up in `/nix/store`, which is world-readable. Always use `environmentFiles` with a secrets manager.
|
||||
:::
|
||||
|
||||
Both `environment` (non-secret vars) and `environmentFiles` (secret files) are merged into `$HERMES_HOME/.env` at activation time (`nixos-rebuild switch`). Hermes reads this file on every startup, so changes take effect with a `systemctl restart hermes-agent` — no container recreation needed.
|
||||
|
||||
### sops-nix
|
||||
|
||||
```nix
|
||||
{
|
||||
sops = {
|
||||
defaultSopsFile = ./secrets/hermes.yaml;
|
||||
age.keyFile = "/home/user/.config/sops/age/keys.txt";
|
||||
secrets."hermes-env" = { format = "yaml"; };
|
||||
};
|
||||
|
||||
services.hermes-agent.environmentFiles = [
|
||||
config.sops.secrets."hermes-env".path
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
The secrets file contains key-value pairs:
|
||||
|
||||
```yaml
|
||||
# secrets/hermes.yaml (encrypted with sops)
|
||||
hermes-env: |
|
||||
OPENROUTER_API_KEY=sk-or-...
|
||||
TELEGRAM_BOT_TOKEN=123456:ABC...
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
```
|
||||
|
||||
### agenix
|
||||
|
||||
```nix
|
||||
{
|
||||
age.secrets.hermes-env.file = ./secrets/hermes-env.age;
|
||||
|
||||
services.hermes-agent.environmentFiles = [
|
||||
config.age.secrets.hermes-env.path
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### OAuth / Auth Seeding
|
||||
|
||||
For platforms requiring OAuth (e.g., Discord), use `authFile` to seed credentials on first deploy:
|
||||
|
||||
```nix
|
||||
{
|
||||
services.hermes-agent = {
|
||||
authFile = config.sops.secrets."hermes/auth.json".path;
|
||||
# authFileForceOverwrite = true; # overwrite on every activation
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
The file is only copied if `auth.json` doesn't already exist (unless `authFileForceOverwrite = true`). Runtime OAuth token refreshes are written to the state directory and preserved across rebuilds.
|
||||
|
||||
---
|
||||
|
||||
## Documents
|
||||
|
||||
The `documents` option installs files into the agent's working directory (the `workingDirectory`, which the agent reads as its workspace). Hermes looks for specific filenames by convention:
|
||||
|
||||
- **`SOUL.md`** — the agent's system prompt / personality. Hermes reads this on startup and uses it as persistent instructions that shape its behavior across all conversations.
|
||||
- **`USER.md`** — context about the user the agent is interacting with.
|
||||
- Any other files you place here are visible to the agent as workspace files.
|
||||
|
||||
```nix
|
||||
{
|
||||
services.hermes-agent.documents = {
|
||||
"SOUL.md" = ''
|
||||
You are a helpful research assistant specializing in NixOS packaging.
|
||||
Always cite sources and prefer reproducible solutions.
|
||||
'';
|
||||
"USER.md" = ./documents/USER.md; # path reference, copied from Nix store
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Values can be inline strings or path references. Files are installed on every `nixos-rebuild switch`.
|
||||
|
||||
---
|
||||
|
||||
## MCP Servers
|
||||
|
||||
The `mcpServers` option declaratively configures [MCP (Model Context Protocol)](https://modelcontextprotocol.io) servers. Each server uses either **stdio** (local command) or **HTTP** (remote URL) transport.
|
||||
|
||||
### Stdio Transport (Local Servers)
|
||||
|
||||
```nix
|
||||
{
|
||||
services.hermes-agent.mcpServers = {
|
||||
filesystem = {
|
||||
command = "npx";
|
||||
args = [ "-y" "@modelcontextprotocol/server-filesystem" "/data/workspace" ];
|
||||
};
|
||||
github = {
|
||||
command = "npx";
|
||||
args = [ "-y" "@modelcontextprotocol/server-github" ];
|
||||
env.GITHUB_PERSONAL_ACCESS_TOKEN = "\${GITHUB_TOKEN}"; # resolved from .env
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
:::tip
|
||||
Environment variables in `env` values are resolved from `$HERMES_HOME/.env` at runtime. Use `environmentFiles` to inject secrets — never put tokens directly in Nix config.
|
||||
:::
|
||||
|
||||
### HTTP Transport (Remote Servers)
|
||||
|
||||
```nix
|
||||
{
|
||||
services.hermes-agent.mcpServers.remote-api = {
|
||||
url = "https://mcp.example.com/v1/mcp";
|
||||
headers.Authorization = "Bearer \${MCP_REMOTE_API_KEY}";
|
||||
timeout = 180;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### HTTP Transport with OAuth
|
||||
|
||||
Set `auth = "oauth"` for servers using OAuth 2.1. Hermes implements the full PKCE flow — metadata discovery, dynamic client registration, token exchange, and automatic refresh.
|
||||
|
||||
```nix
|
||||
{
|
||||
services.hermes-agent.mcpServers.my-oauth-server = {
|
||||
url = "https://mcp.example.com/mcp";
|
||||
auth = "oauth";
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Tokens are stored in `$HERMES_HOME/mcp-tokens/<server-name>.json` and persist across restarts and rebuilds.
|
||||
|
||||
<details>
|
||||
<summary><strong>Initial OAuth authorization on headless servers</strong></summary>
|
||||
|
||||
The first OAuth authorization requires a browser-based consent flow. In a headless deployment, Hermes prints the authorization URL to stdout/logs instead of opening a browser.
|
||||
|
||||
**Option A: Interactive bootstrap** — run the flow once via `docker exec` (container) or `sudo -u hermes` (native):
|
||||
|
||||
```bash
|
||||
# Container mode
|
||||
docker exec -it hermes-agent \
|
||||
hermes mcp add my-oauth-server --url https://mcp.example.com/mcp --auth oauth
|
||||
|
||||
# Native mode
|
||||
sudo -u hermes HERMES_HOME=/var/lib/hermes/.hermes \
|
||||
hermes mcp add my-oauth-server --url https://mcp.example.com/mcp --auth oauth
|
||||
```
|
||||
|
||||
The container uses `--network=host`, so the OAuth callback listener on `127.0.0.1` is reachable from the host browser.
|
||||
|
||||
**Option B: Pre-seed tokens** — complete the flow on a workstation, then copy tokens:
|
||||
|
||||
```bash
|
||||
hermes mcp add my-oauth-server --url https://mcp.example.com/mcp --auth oauth
|
||||
scp ~/.hermes/mcp-tokens/my-oauth-server{,.client}.json \
|
||||
server:/var/lib/hermes/.hermes/mcp-tokens/
|
||||
# Ensure: chown hermes:hermes, chmod 0600
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Sampling (Server-Initiated LLM Requests)
|
||||
|
||||
Some MCP servers can request LLM completions from the agent:
|
||||
|
||||
```nix
|
||||
{
|
||||
services.hermes-agent.mcpServers.analysis = {
|
||||
command = "npx";
|
||||
args = [ "-y" "analysis-server" ];
|
||||
sampling = {
|
||||
enabled = true;
|
||||
model = "google/gemini-3-flash";
|
||||
max_tokens_cap = 4096;
|
||||
timeout = 30;
|
||||
max_rpm = 10;
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Managed Mode
|
||||
|
||||
When hermes runs via the NixOS module, the following CLI commands are **blocked** with a descriptive error pointing you to `configuration.nix`:
|
||||
|
||||
| Blocked command | Why |
|
||||
|---|---|
|
||||
| `hermes setup` | Config is declarative — edit `settings` in your Nix config |
|
||||
| `hermes config edit` | Config is generated from `settings` |
|
||||
| `hermes config set <key> <value>` | Config is generated from `settings` |
|
||||
| `hermes gateway install` | The systemd service is managed by NixOS |
|
||||
| `hermes gateway uninstall` | The systemd service is managed by NixOS |
|
||||
|
||||
This prevents drift between what Nix declares and what's on disk. Detection uses two signals:
|
||||
|
||||
1. **`HERMES_MANAGED=true`** environment variable — set by the systemd service, visible to the gateway process
|
||||
2. **`.managed` marker file** in `HERMES_HOME` — set by the activation script, visible to interactive shells (e.g., `docker exec -it hermes-agent hermes config set ...` is also blocked)
|
||||
|
||||
To change configuration, edit your Nix config and run `sudo nixos-rebuild switch`.
|
||||
|
||||
---
|
||||
|
||||
## Container Architecture
|
||||
|
||||
:::info
|
||||
This section is only relevant if you're using `container.enable = true`. Skip it for native mode deployments.
|
||||
:::
|
||||
|
||||
When container mode is enabled, hermes runs inside a persistent Ubuntu container with the Nix-built binary bind-mounted read-only from the host:
|
||||
|
||||
```
|
||||
Host Container
|
||||
──── ─────────
|
||||
/nix/store/...-hermes-agent-0.1.0 ──► /nix/store/... (ro)
|
||||
/var/lib/hermes/ ──► /data/ (rw)
|
||||
├── current-package -> /nix/store/... (symlink, updated each rebuild)
|
||||
├── .gc-root -> /nix/store/... (prevents nix-collect-garbage)
|
||||
├── .container-identity (sha256 hash, triggers recreation)
|
||||
├── .hermes/ (HERMES_HOME)
|
||||
│ ├── .env (merged from environment + environmentFiles)
|
||||
│ ├── config.yaml (Nix-generated, deep-merged by activation)
|
||||
│ ├── .managed (marker file)
|
||||
│ ├── state.db, sessions/, memories/ (runtime state)
|
||||
│ └── mcp-tokens/ (OAuth tokens for MCP servers)
|
||||
├── home/ ──► /home/hermes (rw)
|
||||
└── workspace/ (MESSAGING_CWD)
|
||||
├── SOUL.md (from documents option)
|
||||
└── (agent-created files)
|
||||
|
||||
Container writable layer (apt/pip/npm): /usr, /usr/local, /tmp
|
||||
```
|
||||
|
||||
The Nix-built binary works inside the Ubuntu container because `/nix/store` is bind-mounted — it brings its own interpreter and all dependencies, so there's no reliance on the container's system libraries. The container entrypoint resolves through a `current-package` symlink: `/data/current-package/bin/hermes gateway run --replace`. On `nixos-rebuild switch`, only the symlink is updated — the container keeps running.
|
||||
|
||||
### What Persists Across What
|
||||
|
||||
| Event | Container recreated? | `/data` (state) | `/home/hermes` | Writable layer (`apt`/`pip`/`npm`) |
|
||||
|---|---|---|---|---|
|
||||
| `systemctl restart hermes-agent` | No | Persists | Persists | Persists |
|
||||
| `nixos-rebuild switch` (code change) | No (symlink updated) | Persists | Persists | Persists |
|
||||
| Host reboot | No | Persists | Persists | Persists |
|
||||
| `nix-collect-garbage` | No (GC root) | Persists | Persists | Persists |
|
||||
| Image change (`container.image`) | **Yes** | Persists | Persists | **Lost** |
|
||||
| Volume/options change | **Yes** | Persists | Persists | **Lost** |
|
||||
| `environment`/`environmentFiles` change | No | Persists | Persists | Persists |
|
||||
|
||||
The container is only recreated when its **identity hash** changes. The hash covers: schema version, image, `extraVolumes`, `extraOptions`, and the entrypoint script. Changes to environment variables, settings, documents, or the hermes package itself do **not** trigger recreation.
|
||||
|
||||
:::warning Writable layer loss
|
||||
When the identity hash changes (image upgrade, new volumes, new container options), the container is destroyed and recreated from a fresh pull of `container.image`. Any `apt install`, `pip install`, or `npm install` packages in the writable layer are lost. State in `/data` and `/home/hermes` is preserved (these are bind mounts).
|
||||
|
||||
If the agent relies on specific packages, consider baking them into a custom image (`container.image = "my-registry/hermes-base:latest"`) or scripting their installation in the agent's SOUL.md.
|
||||
:::
|
||||
|
||||
### GC Root Protection
|
||||
|
||||
The `preStart` script creates a GC root at `${stateDir}/.gc-root` pointing to the current hermes package. This prevents `nix-collect-garbage` from removing the running binary. If the GC root somehow breaks, restarting the service recreates it.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### Dev Shell
|
||||
|
||||
The flake provides a development shell with Python 3.11, uv, Node.js, and all runtime tools:
|
||||
|
||||
```bash
|
||||
cd hermes-agent
|
||||
nix develop
|
||||
|
||||
# Shell provides:
|
||||
# - Python 3.11 + uv (deps installed into .venv on first entry)
|
||||
# - Node.js 20, ripgrep, git, openssh, ffmpeg on PATH
|
||||
# - Stamp-file optimization: re-entry is near-instant if deps haven't changed
|
||||
|
||||
hermes setup
|
||||
hermes chat
|
||||
```
|
||||
|
||||
### direnv (Recommended)
|
||||
|
||||
The included `.envrc` activates the dev shell automatically:
|
||||
|
||||
```bash
|
||||
cd hermes-agent
|
||||
direnv allow # one-time
|
||||
# Subsequent entries are near-instant (stamp file skips dep install)
|
||||
```
|
||||
|
||||
### Flake Checks
|
||||
|
||||
The flake includes build-time verification that runs in CI and locally:
|
||||
|
||||
```bash
|
||||
# Run all checks
|
||||
nix flake check
|
||||
|
||||
# Individual checks
|
||||
nix build .#checks.x86_64-linux.package-contents # binaries exist + version
|
||||
nix build .#checks.x86_64-linux.entry-points-sync # pyproject.toml ↔ Nix package sync
|
||||
nix build .#checks.x86_64-linux.cli-commands # gateway/config subcommands
|
||||
nix build .#checks.x86_64-linux.managed-guard # HERMES_MANAGED blocks mutation
|
||||
nix build .#checks.x86_64-linux.bundled-skills # skills present in package
|
||||
nix build .#checks.x86_64-linux.config-roundtrip # merge script preserves user keys
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><strong>What each check verifies</strong></summary>
|
||||
|
||||
| Check | What it tests |
|
||||
|---|---|
|
||||
| `package-contents` | `hermes` and `hermes-agent` binaries exist and `hermes version` runs |
|
||||
| `entry-points-sync` | Every `[project.scripts]` entry in `pyproject.toml` has a wrapped binary in the Nix package |
|
||||
| `cli-commands` | `hermes --help` exposes `gateway` and `config` subcommands |
|
||||
| `managed-guard` | `HERMES_MANAGED=true hermes config set ...` prints the NixOS error |
|
||||
| `bundled-skills` | Skills directory exists, contains SKILL.md files, `HERMES_BUNDLED_SKILLS` is set in wrapper |
|
||||
| `config-roundtrip` | 7 merge scenarios: fresh install, Nix override, user key preservation, mixed merge, MCP additive merge, nested deep merge, idempotency |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Options Reference
|
||||
|
||||
### Core
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `enable` | `bool` | `false` | Enable the hermes-agent service |
|
||||
| `package` | `package` | `hermes-agent` | The hermes-agent package to use |
|
||||
| `user` | `str` | `"hermes"` | System user |
|
||||
| `group` | `str` | `"hermes"` | System group |
|
||||
| `createUser` | `bool` | `true` | Auto-create user/group |
|
||||
| `stateDir` | `str` | `"/var/lib/hermes"` | State directory (`HERMES_HOME` parent) |
|
||||
| `workingDirectory` | `str` | `"${stateDir}/workspace"` | Agent working directory (`MESSAGING_CWD`) |
|
||||
| `addToSystemPackages` | `bool` | `false` | Add `hermes` CLI to system PATH and set `HERMES_HOME` system-wide |
|
||||
|
||||
### Configuration
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `settings` | `attrs` (deep-merged) | `{}` | Declarative config rendered as `config.yaml`. Supports arbitrary nesting; multiple definitions are merged via `lib.recursiveUpdate` |
|
||||
| `configFile` | `null` or `path` | `null` | Path to an existing `config.yaml`. Overrides `settings` entirely if set |
|
||||
|
||||
### Secrets & Environment
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `environmentFiles` | `listOf str` | `[]` | Paths to env files with secrets. Merged into `$HERMES_HOME/.env` at activation time |
|
||||
| `environment` | `attrsOf str` | `{}` | Non-secret env vars. **Visible in Nix store** — do not put secrets here |
|
||||
| `authFile` | `null` or `path` | `null` | OAuth credentials seed. Only copied on first deploy |
|
||||
| `authFileForceOverwrite` | `bool` | `false` | Always overwrite `auth.json` from `authFile` on activation |
|
||||
|
||||
### Documents
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `documents` | `attrsOf (either str path)` | `{}` | Workspace files. Keys are filenames, values are inline strings or paths. Installed into `workingDirectory` on activation |
|
||||
|
||||
### MCP Servers
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `mcpServers` | `attrsOf submodule` | `{}` | MCP server definitions, merged into `settings.mcp_servers` |
|
||||
| `mcpServers.<name>.command` | `null` or `str` | `null` | Server command (stdio transport) |
|
||||
| `mcpServers.<name>.args` | `listOf str` | `[]` | Command arguments |
|
||||
| `mcpServers.<name>.env` | `attrsOf str` | `{}` | Environment variables for the server process |
|
||||
| `mcpServers.<name>.url` | `null` or `str` | `null` | Server endpoint URL (HTTP/StreamableHTTP transport) |
|
||||
| `mcpServers.<name>.headers` | `attrsOf str` | `{}` | HTTP headers, e.g. `Authorization` |
|
||||
| `mcpServers.<name>.auth` | `null` or `"oauth"` | `null` | Authentication method. `"oauth"` enables OAuth 2.1 PKCE |
|
||||
| `mcpServers.<name>.enabled` | `bool` | `true` | Enable or disable this server |
|
||||
| `mcpServers.<name>.timeout` | `null` or `int` | `null` | Tool call timeout in seconds (default: 120) |
|
||||
| `mcpServers.<name>.connect_timeout` | `null` or `int` | `null` | Connection timeout in seconds (default: 60) |
|
||||
| `mcpServers.<name>.tools` | `null` or `submodule` | `null` | Tool filtering (`include`/`exclude` lists) |
|
||||
| `mcpServers.<name>.sampling` | `null` or `submodule` | `null` | Sampling config for server-initiated LLM requests |
|
||||
|
||||
### Service Behavior
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `extraArgs` | `listOf str` | `[]` | Extra args for `hermes gateway` |
|
||||
| `extraPackages` | `listOf package` | `[]` | Extra packages on service PATH (native mode only) |
|
||||
| `restart` | `str` | `"always"` | systemd `Restart=` policy |
|
||||
| `restartSec` | `int` | `5` | systemd `RestartSec=` value |
|
||||
|
||||
### Container
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `container.enable` | `bool` | `false` | Enable OCI container mode |
|
||||
| `container.backend` | `enum ["docker" "podman"]` | `"docker"` | Container runtime |
|
||||
| `container.image` | `str` | `"ubuntu:24.04"` | Base image (pulled at runtime) |
|
||||
| `container.extraVolumes` | `listOf str` | `[]` | Extra volume mounts (`host:container:mode`) |
|
||||
| `container.extraOptions` | `listOf str` | `[]` | Extra args passed to `docker create` |
|
||||
|
||||
---
|
||||
|
||||
## Directory Layout
|
||||
|
||||
### Native Mode
|
||||
|
||||
```
|
||||
/var/lib/hermes/ # stateDir (owned by hermes:hermes, 0750)
|
||||
├── .hermes/ # HERMES_HOME
|
||||
│ ├── config.yaml # Nix-generated (deep-merged each rebuild)
|
||||
│ ├── .managed # Marker: CLI config mutation blocked
|
||||
│ ├── .env # Merged from environment + environmentFiles
|
||||
│ ├── auth.json # OAuth credentials (seeded, then self-managed)
|
||||
│ ├── gateway.pid
|
||||
│ ├── state.db
|
||||
│ ├── mcp-tokens/ # OAuth tokens for MCP servers
|
||||
│ ├── sessions/
|
||||
│ ├── memories/
|
||||
│ ├── skills/
|
||||
│ ├── cron/
|
||||
│ └── logs/
|
||||
├── home/ # Agent HOME
|
||||
└── workspace/ # MESSAGING_CWD
|
||||
├── SOUL.md # From documents option
|
||||
└── (agent-created files)
|
||||
```
|
||||
|
||||
### Container Mode
|
||||
|
||||
Same layout, mounted into the container:
|
||||
|
||||
| Container path | Host path | Mode | Notes |
|
||||
|---|---|---|---|
|
||||
| `/nix/store` | `/nix/store` | `ro` | Hermes binary + all Nix deps |
|
||||
| `/data` | `/var/lib/hermes` | `rw` | All state, config, workspace |
|
||||
| `/home/hermes` | `${stateDir}/home` | `rw` | Persistent agent home — `pip install --user`, tool caches |
|
||||
| `/usr`, `/usr/local`, `/tmp` | (writable layer) | `rw` | `apt`/`pip`/`npm` installs — persists across restarts, lost on recreation |
|
||||
|
||||
---
|
||||
|
||||
## Updating
|
||||
|
||||
```bash
|
||||
# Update the flake input
|
||||
nix flake update hermes-agent --flake /etc/nixos
|
||||
|
||||
# Rebuild
|
||||
sudo nixos-rebuild switch
|
||||
```
|
||||
|
||||
In container mode, the `current-package` symlink is updated and the agent picks up the new binary on restart. No container recreation, no loss of installed packages.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
:::tip Podman users
|
||||
All `docker` commands below work the same with `podman`. Substitute accordingly if you set `container.backend = "podman"`.
|
||||
:::
|
||||
|
||||
### Service Logs
|
||||
|
||||
```bash
|
||||
# Both modes use the same systemd unit
|
||||
journalctl -u hermes-agent -f
|
||||
|
||||
# Container mode: also available directly
|
||||
docker logs -f hermes-agent
|
||||
```
|
||||
|
||||
### Container Inspection
|
||||
|
||||
```bash
|
||||
systemctl status hermes-agent
|
||||
docker ps -a --filter name=hermes-agent
|
||||
docker inspect hermes-agent --format='{{.State.Status}}'
|
||||
docker exec -it hermes-agent bash
|
||||
docker exec hermes-agent readlink /data/current-package
|
||||
docker exec hermes-agent cat /data/.container-identity
|
||||
```
|
||||
|
||||
### Force Container Recreation
|
||||
|
||||
If you need to reset the writable layer (fresh Ubuntu):
|
||||
|
||||
```bash
|
||||
sudo systemctl stop hermes-agent
|
||||
docker rm -f hermes-agent
|
||||
sudo rm /var/lib/hermes/.container-identity
|
||||
sudo systemctl start hermes-agent
|
||||
```
|
||||
|
||||
### Verify Secrets Are Loaded
|
||||
|
||||
If the agent starts but can't authenticate with the LLM provider, check that the `.env` file was merged correctly:
|
||||
|
||||
```bash
|
||||
# Native mode
|
||||
sudo -u hermes cat /var/lib/hermes/.hermes/.env
|
||||
|
||||
# Container mode
|
||||
docker exec hermes-agent cat /data/.hermes/.env
|
||||
```
|
||||
|
||||
### GC Root Verification
|
||||
|
||||
```bash
|
||||
nix-store --query --roots $(docker exec hermes-agent readlink /data/current-package)
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| `Cannot save configuration: managed by NixOS` | CLI guards active | Edit `configuration.nix` and `nixos-rebuild switch` |
|
||||
| Container recreated unexpectedly | `extraVolumes`, `extraOptions`, or `image` changed | Expected — writable layer resets. Reinstall packages or use a custom image |
|
||||
| `hermes version` shows old version | Container not restarted | `systemctl restart hermes-agent` |
|
||||
| Permission denied on `/var/lib/hermes` | State dir is `0750 hermes:hermes` | Use `docker exec` or `sudo -u hermes` |
|
||||
| `nix-collect-garbage` removed hermes | GC root missing | Restart the service (preStart recreates the GC root) |
|
||||
@@ -9,6 +9,7 @@ const sidebars: SidebarsConfig = {
|
||||
items: [
|
||||
'getting-started/quickstart',
|
||||
'getting-started/installation',
|
||||
'getting-started/nix-setup',
|
||||
'getting-started/updating',
|
||||
'getting-started/learning-path',
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user