""" Configuration management for Hermes Agent. Config files are stored in ~/.hermes/ for easy access: - ~/.hermes/config.yaml - All settings (model, toolsets, terminal, etc.) - ~/.hermes/.env - API keys and secrets This module provides: - hermes config - Show current configuration - hermes config edit - Open config in editor - hermes config set - Set a specific value - hermes config wizard - Re-run setup wizard """ import copy import logging import os import platform import re import stat import subprocess import sys import tempfile from dataclasses import dataclass from pathlib import Path from typing import Dict, Any, Optional, List, Tuple logger = logging.getLogger(__name__) _IS_WINDOWS = platform.system() == "Windows" _ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") _LAST_EXPANDED_CONFIG_BY_PATH: Dict[str, Any] = {} # (path, mtime_ns, size) -> cached expanded config dict. # load_config() returns a deepcopy of the cached value when the file # hasn't changed since the last load, skipping yaml.safe_load + # _deep_merge + _normalize_* + _expand_env_vars (~13 ms/call). # save_config() + migrate_config() write via atomic_yaml_write which # produces a fresh inode, so stat() sees a new mtime_ns and the next # load repopulates automatically — no explicit invalidation hook. _LOAD_CONFIG_CACHE: Dict[str, Tuple[int, int, Dict[str, Any]]] = {} # (path, mtime_ns, size) -> cached raw yaml dict. Same pattern as # _LOAD_CONFIG_CACHE but for read_raw_config() — used when callers want # the user's on-disk values without defaults merged in. _RAW_CONFIG_CACHE: Dict[str, Tuple[int, int, Dict[str, Any]]] = {} # Env var names written to .env that aren't in OPTIONAL_ENV_VARS # (managed by setup/provider flows directly). _EXTRA_ENV_KEYS = frozenset({ "OPENAI_API_KEY", "OPENAI_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "DISCORD_HOME_CHANNEL", "DISCORD_HOME_CHANNEL_NAME", "TELEGRAM_HOME_CHANNEL", "TELEGRAM_HOME_CHANNEL_NAME", "SLACK_HOME_CHANNEL", "SLACK_HOME_CHANNEL_NAME", "SIGNAL_ACCOUNT", "SIGNAL_HTTP_URL", "SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS", "SIGNAL_HOME_CHANNEL", "SIGNAL_HOME_CHANNEL_NAME", "SMS_HOME_CHANNEL", "SMS_HOME_CHANNEL_NAME", "DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET", "DINGTALK_HOME_CHANNEL", "DINGTALK_HOME_CHANNEL_NAME", "FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_ENCRYPT_KEY", "FEISHU_VERIFICATION_TOKEN", "FEISHU_HOME_CHANNEL", "FEISHU_HOME_CHANNEL_NAME", "YUANBAO_HOME_CHANNEL", "YUANBAO_HOME_CHANNEL_NAME", "WECOM_BOT_ID", "WECOM_SECRET", "WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET", "WECOM_CALLBACK_AGENT_ID", "WECOM_CALLBACK_TOKEN", "WECOM_CALLBACK_ENCODING_AES_KEY", "WECOM_CALLBACK_HOST", "WECOM_CALLBACK_PORT", "WECOM_HOME_CHANNEL", "WECOM_HOME_CHANNEL_NAME", "WEIXIN_ACCOUNT_ID", "WEIXIN_TOKEN", "WEIXIN_BASE_URL", "WEIXIN_CDN_BASE_URL", "WEIXIN_HOME_CHANNEL", "WEIXIN_HOME_CHANNEL_NAME", "WEIXIN_DM_POLICY", "WEIXIN_GROUP_POLICY", "WEIXIN_ALLOWED_USERS", "WEIXIN_GROUP_ALLOWED_USERS", "WEIXIN_ALLOW_ALL_USERS", "BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD", "BLUEBUBBLES_HOME_CHANNEL", "BLUEBUBBLES_HOME_CHANNEL_NAME", "QQ_APP_ID", "QQ_CLIENT_SECRET", "QQBOT_HOME_CHANNEL", "QQBOT_HOME_CHANNEL_NAME", "QQ_HOME_CHANNEL", "QQ_HOME_CHANNEL_NAME", # legacy aliases (pre-rename, still read for back-compat) "QQ_ALLOWED_USERS", "QQ_GROUP_ALLOWED_USERS", "QQ_ALLOW_ALL_USERS", "QQ_MARKDOWN_SUPPORT", "QQ_STT_API_KEY", "QQ_STT_BASE_URL", "QQ_STT_MODEL", "TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT", "WHATSAPP_MODE", "WHATSAPP_ENABLED", "MATTERMOST_HOME_CHANNEL", "MATTERMOST_HOME_CHANNEL_NAME", "MATTERMOST_REPLY_MODE", "MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_DEVICE_ID", "MATRIX_HOME_ROOM", "MATRIX_REQUIRE_MENTION", "MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_AUTO_THREAD", "MATRIX_DM_AUTO_THREAD", "MATRIX_RECOVERY_KEY", # Langfuse observability plugin — optional tuning keys + standard SDK vars. # Activation is via plugins.enabled (opt-in through `hermes plugins enable # observability/langfuse` or `hermes tools → Langfuse`); credentials gate # the plugin at runtime. "HERMES_LANGFUSE_ENV", "HERMES_LANGFUSE_RELEASE", "HERMES_LANGFUSE_SAMPLE_RATE", "HERMES_LANGFUSE_MAX_CHARS", "HERMES_LANGFUSE_DEBUG", "LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY", "LANGFUSE_BASE_URL", }) import yaml from hermes_cli.colors import Colors, color from hermes_cli.default_soul import DEFAULT_SOUL_MD # ============================================================================= # Managed mode (NixOS declarative config) # ============================================================================= _MANAGED_TRUE_VALUES = ("true", "1", "yes") _MANAGED_SYSTEM_NAMES = { "brew": "Homebrew", "homebrew": "Homebrew", "nix": "NixOS", "nixos": "NixOS", } def get_managed_system() -> Optional[str]: """Return the package manager owning this install, if any.""" raw = os.getenv("HERMES_MANAGED", "").strip() if raw: normalized = raw.lower() if normalized in _MANAGED_TRUE_VALUES: return "NixOS" return _MANAGED_SYSTEM_NAMES.get(normalized, raw) managed_marker = get_hermes_home() / ".managed" if managed_marker.exists(): return "NixOS" return None def is_managed() -> bool: """Check if Hermes is running in package-manager-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). """ return get_managed_system() is not None def get_managed_update_command() -> Optional[str]: """Return the preferred upgrade command for a managed install.""" managed_system = get_managed_system() if managed_system == "Homebrew": return "brew upgrade hermes-agent" if managed_system == "NixOS": return "sudo nixos-rebuild switch" return None def recommended_update_command() -> str: """Return the best update command for the current installation.""" return get_managed_update_command() or "hermes update" def format_managed_message(action: str = "modify this Hermes installation") -> str: """Build a user-facing error for managed installs.""" managed_system = get_managed_system() or "a package manager" raw = os.getenv("HERMES_MANAGED", "").strip().lower() if managed_system == "NixOS": env_hint = "true" if raw in _MANAGED_TRUE_VALUES else raw or "true" return ( f"Cannot {action}: this Hermes installation is managed by NixOS " f"(HERMES_MANAGED={env_hint}).\n" "Edit services.hermes-agent.settings in your configuration.nix and run:\n" " sudo nixos-rebuild switch" ) if managed_system == "Homebrew": env_hint = raw or "homebrew" return ( f"Cannot {action}: this Hermes installation is managed by Homebrew " f"(HERMES_MANAGED={env_hint}).\n" "Use:\n" " brew upgrade hermes-agent" ) return ( f"Cannot {action}: this Hermes installation is managed by {managed_system}.\n" "Use your package manager to upgrade or reinstall Hermes." ) def managed_error(action: str = "modify configuration"): """Print user-friendly error for managed mode.""" print(format_managed_message(action), file=sys.stderr) # ============================================================================= # Container-aware CLI (NixOS container mode) # ============================================================================= def get_container_exec_info() -> Optional[dict]: """Read container mode metadata from HERMES_HOME/.container-mode. Returns a dict with keys: backend, container_name, exec_user, hermes_bin or None if container mode is not active, we're already inside the container, or HERMES_DEV=1 is set. The .container-mode file is written by the NixOS activation script when container.enable = true. It tells the host CLI to exec into the container instead of running locally. """ if os.environ.get("HERMES_DEV") == "1": return None from hermes_constants import is_container if is_container(): return None container_mode_file = get_hermes_home() / ".container-mode" try: info = {} with open(container_mode_file, "r") as f: for line in f: line = line.strip() if "=" in line and not line.startswith("#"): key, _, value = line.partition("=") info[key.strip()] = value.strip() except FileNotFoundError: return None # All other exceptions (PermissionError, malformed data, etc.) propagate backend = info.get("backend", "docker") container_name = info.get("container_name", "hermes-agent") exec_user = info.get("exec_user", "hermes") hermes_bin = info.get("hermes_bin", "/data/current-package/bin/hermes") return { "backend": backend, "container_name": container_name, "exec_user": exec_user, "hermes_bin": hermes_bin, } # ============================================================================= # Config paths # ============================================================================= # Re-export from hermes_constants — canonical definition lives there. from hermes_constants import get_hermes_home # noqa: F811,E402 from utils import atomic_replace def get_config_path() -> Path: """Get the main config file path.""" return get_hermes_home() / "config.yaml" def get_env_path() -> Path: """Get the .env file path (for API keys).""" return get_hermes_home() / ".env" def get_project_root() -> Path: """Get the project installation directory.""" return Path(__file__).parent.parent.resolve() def _secure_dir(path): """Set directory to owner-only access (0700 by default). No-op on Windows. Skipped in managed mode — the NixOS module sets group-readable permissions (0750) so interactive users in the hermes group can share state with the gateway service. The mode can be overridden via the HERMES_HOME_MODE environment variable (e.g. HERMES_HOME_MODE=0701) for deployments where a web server (nginx, caddy, etc.) needs to traverse HERMES_HOME to reach a served subdirectory. The execute-only bit on a directory permits cd-through without exposing directory listings. """ if is_managed(): return try: mode_str = os.environ.get("HERMES_HOME_MODE", "").strip() mode = int(mode_str, 8) if mode_str else 0o700 except ValueError: mode = 0o700 try: os.chmod(path, mode) except (OSError, NotImplementedError): pass def _is_container() -> bool: """Detect if we're running inside a Docker/Podman/LXC container. When Hermes runs in a container with volume-mounted config files, forcing 0o600 permissions breaks multi-process setups where the gateway and dashboard run as different UIDs or the volume mount requires broader permissions. """ # Explicit opt-out if os.environ.get("HERMES_CONTAINER") or os.environ.get("HERMES_SKIP_CHMOD"): return True # Docker / Podman marker file if os.path.exists("/.dockerenv"): return True # LXC / cgroup-based detection try: with open("/proc/1/cgroup", "r") as f: cgroup_content = f.read() if "docker" in cgroup_content or "lxc" in cgroup_content or "kubepods" in cgroup_content: return True except (OSError, IOError): pass return False def _secure_file(path): """Set file to owner-only read/write (0600). No-op on Windows. Skipped in managed mode — the NixOS activation script sets group-readable permissions (0640) on config files. Skipped in containers — Docker/Podman volume mounts often need broader permissions. Set HERMES_SKIP_CHMOD=1 to force-skip on other systems. """ if is_managed() or _is_container(): return try: if os.path.exists(str(path)): os.chmod(path, 0o600) except (OSError, NotImplementedError): pass def _ensure_default_soul_md(home: Path) -> None: """Seed a default SOUL.md into HERMES_HOME if the user doesn't have one yet.""" soul_path = home / "SOUL.md" if soul_path.exists(): return soul_path.write_text(DEFAULT_SOUL_MD, encoding="utf-8") _secure_file(soul_path) def ensure_hermes_home(): """Ensure ~/.hermes directory structure exists with secure permissions. In managed mode (NixOS), dirs are created by the activation script with setgid + group-writable (2770). We skip mkdir and set umask(0o007) so any files created (e.g. SOUL.md) are group-writable (0660). """ home = get_hermes_home() if is_managed(): old_umask = os.umask(0o007) try: _ensure_hermes_home_managed(home) finally: os.umask(old_umask) else: home.mkdir(parents=True, exist_ok=True) _secure_dir(home) for subdir in ("cron", "sessions", "logs", "memories"): d = home / subdir d.mkdir(parents=True, exist_ok=True) _secure_dir(d) _ensure_default_soul_md(home) def _ensure_hermes_home_managed(home: Path): """Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.""" if not home.is_dir(): raise RuntimeError( f"HERMES_HOME {home} does not exist. " "Run 'sudo nixos-rebuild switch' first." ) for subdir in ("cron", "sessions", "logs", "memories"): d = home / subdir if not d.is_dir(): raise RuntimeError( f"{d} does not exist. " "Run 'sudo nixos-rebuild switch' first." ) # Inside umask(0o007) scope — SOUL.md will be created as 0660 _ensure_default_soul_md(home) # ============================================================================= # Config loading/saving # ============================================================================= DEFAULT_CONFIG = { "model": "", "providers": {}, "fallback_providers": [], "credential_pool_strategies": {}, "toolsets": ["hermes-cli"], "agent": { "max_turns": 90, # Inactivity timeout for gateway agent execution (seconds). # The agent can run indefinitely as long as it's actively calling # tools or receiving API responses. Only fires when the agent has # been completely idle for this duration. 0 = unlimited. "gateway_timeout": 1800, # Graceful drain timeout for gateway stop/restart (seconds). # The gateway stops accepting new work, waits for running agents # to finish, then interrupts any remaining runs after the timeout. # 0 = no drain, interrupt immediately. "restart_drain_timeout": 60, # Max app-level retry attempts for API errors (connection drops, # provider timeouts, 5xx, etc.) before the agent surfaces the # failure. The OpenAI SDK already does its own low-level retries # (max_retries=2 default) for transient network errors; this is # the Hermes-level retry loop that wraps the whole call. Lower # this to 1 if you use fallback providers and want fast failover # on flaky primaries; raise it if you prefer to tolerate longer # provider hiccups on a single provider. "api_max_retries": 3, "service_tier": "", # Tool-use enforcement: injects system prompt guidance that tells the # model to actually call tools instead of describing intended actions. # Values: "auto" (default — applies to gpt/codex models), true/false # (force on/off for all models), or a list of model-name substrings # to match (e.g. ["gpt", "codex", "gemini", "qwen"]). "tool_use_enforcement": "auto", # Staged inactivity warning: send a warning to the user at this # threshold before escalating to a full timeout. The warning fires # once per run and does not interrupt the agent. 0 = disable warning. "gateway_timeout_warning": 900, # Periodic "still working" notification interval (seconds). # Sends a status message every N seconds so the user knows the # agent hasn't died during long tasks. 0 = disable notifications. # Lower values mean faster feedback on slow tasks but more chat # noise; 180s is a compromise that catches spinning weak-model runs # (60+ tool iterations with tiny output) before users assume the # bot is dead and /restart. "gateway_notify_interval": 180, # Freshness window for the gateway auto-continue note (seconds). # After a gateway crash/restart/SIGTERM mid-run, the next user # message gets a "[System note: your previous turn was # interrupted — process the unfinished tool result(s) first]" # prepended so the model picks up where it left off. That's the # right behaviour while the interruption is fresh, but stale # markers (transcript last touched hours or days ago) can revive # an unrelated old task when the user's next message starts new # work. This window is the max age of the last persisted # transcript row for which we still inject the continue note. # Default 3600s comfortably covers a long turn (gateway_timeout # default is 1800s) plus runtime slack. Set to 0 to disable the # gate and restore pre-fix behaviour (always inject). "gateway_auto_continue_freshness": 3600, # How user-attached images are presented to the main model on each turn. # "auto" — attach natively when the active model reports # supports_vision=True AND the user hasn't explicitly # configured auxiliary.vision.provider. Otherwise fall # back to text (vision_analyze pre-analysis). # "native" — always attach natively; non-vision models will either # error at the provider or get a last-chance text fallback # (see run_agent._prepare_messages_for_api). # "text" — always pre-analyze with vision_analyze and prepend the # description as text; the main model never sees pixels. # Affects gateway platforms, the TUI, and CLI /attach. vision_analyze # remains available as a tool regardless of this setting — the routing # only controls how inbound user images are presented. "image_input_mode": "auto", }, "terminal": { "backend": "local", "modal_mode": "auto", "cwd": ".", # Use current directory "timeout": 180, # Environment variables to pass through to sandboxed execution # (terminal and execute_code). Skill-declared required_environment_variables # are passed through automatically; this list is for non-skill use cases. "env_passthrough": [], # Extra files to source in the login shell when building the # per-session environment snapshot. Use this when tools like nvm, # pyenv, asdf, or custom PATH entries are registered by files that # a bash login shell would skip — most commonly ``~/.bashrc`` # (bash doesn't source bashrc in non-interactive login mode) or # zsh-specific files like ``~/.zshrc`` / ``~/.zprofile``. # Paths support ``~`` / ``${VAR}``. Missing files are silently # skipped. When empty, Hermes auto-sources ``~/.profile``, # ``~/.bash_profile``, and ``~/.bashrc`` (in that order) if the # snapshot shell is bash (this is the ``auto_source_bashrc`` # behaviour — disable with that key if you want strict login-only # semantics). "shell_init_files": [], # When true (default), Hermes sources the user's shell rc files # (``~/.profile``, ``~/.bash_profile``, ``~/.bashrc``) in the # login shell used to build the environment snapshot. This # captures PATH additions, shell functions, and aliases — which a # plain ``bash -l -c`` would otherwise miss because bash skips # bashrc in non-interactive login mode, and because a default # Debian/Ubuntu ``~/.bashrc`` short-circuits on non-interactive # sources. ``~/.profile`` and ``~/.bash_profile`` are tried first # because ``n`` / ``nvm`` / ``asdf`` installers typically write # their PATH exports there without an interactivity guard. Turn # this off if your rc files misbehave when sourced # non-interactively (e.g. one that hard-exits on TTY checks). "auto_source_bashrc": True, "docker_image": "nikolaik/python-nodejs:python3.11-nodejs20", "docker_forward_env": [], # Explicit environment variables to set inside Docker containers. # Unlike docker_forward_env (which reads values from the host process), # docker_env lets you specify exact key-value pairs — useful when Hermes # runs as a systemd service without access to the user's shell environment. # Example: {"SSH_AUTH_SOCK": "/run/user/1000/ssh-agent.sock"} "docker_env": {}, "singularity_image": "docker://nikolaik/python-nodejs:python3.11-nodejs20", "modal_image": "nikolaik/python-nodejs:python3.11-nodejs20", "daytona_image": "nikolaik/python-nodejs:python3.11-nodejs20", # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh) "container_cpu": 1, "container_memory": 5120, # MB (default 5GB) "container_disk": 51200, # MB (default 50GB) "container_persistent": True, # Persist filesystem across sessions # Docker volume mounts — share host directories with the container. # Each entry is "host_path:container_path" (standard Docker -v syntax). # Example: # ["/home/user/projects:/workspace/projects", # "/home/user/.hermes/cache/documents:/output"] # For gateway MEDIA delivery, write inside Docker to /output/... and emit # the host-visible path in MEDIA:, not the container path. "docker_volumes": [], # Explicit opt-in: mount the host cwd into /workspace for Docker sessions. # Default off because passing host directories into a sandbox weakens isolation. "docker_mount_cwd_to_workspace": False, # Persistent shell — keep a long-lived bash shell across execute() calls # so cwd/env vars/shell variables survive between commands. # Enabled by default for non-local backends (SSH); local is always opt-in # via TERMINAL_LOCAL_PERSISTENT env var. "persistent_shell": True, }, "browser": { "inactivity_timeout": 120, "command_timeout": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.) "record_sessions": False, # Auto-record browser sessions as WebM videos "allow_private_urls": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.) "auto_local_for_private_urls": True, # When a cloud provider is set, auto-spawn local Chromium for LAN/localhost URLs instead of sending them to the cloud "cdp_url": "", # Optional persistent CDP endpoint for attaching to an existing Chromium/Chrome # CDP supervisor — dialog + frame detection via a persistent WebSocket. # Active only when a CDP-capable backend is attached (Browserbase or # local Chrome via /browser connect). See # website/docs/developer-guide/browser-supervisor.md. "dialog_policy": "must_respond", # must_respond | auto_dismiss | auto_accept "dialog_timeout_s": 300, # Safety auto-dismiss after N seconds under must_respond "camofox": { # When true, Hermes sends a stable profile-scoped userId to Camofox # so the server maps it to a persistent Firefox profile automatically. # When false (default), each session gets a random userId (ephemeral). "managed_persistence": False, }, }, # Filesystem checkpoints — automatic snapshots before destructive file ops. # When enabled, the agent takes a snapshot of the working directory once per # conversation turn (on first write_file/patch call). Use /rollback to restore. "checkpoints": { "enabled": True, "max_snapshots": 50, # Max checkpoints to keep per directory # Auto-maintenance: shadow repos accumulate forever under # ~/.hermes/checkpoints/ (one per cd'd working directory). Field # reports put the typical offender at 1000+ repos / ~12 GB. When # auto_prune is on, hermes sweeps at startup (at most once per # min_interval_hours) and deletes: # * orphan repos: HERMES_WORKDIR no longer exists on disk # * stale repos: newest mtime older than retention_days # Opt-in so users who rely on /rollback against long-ago sessions # never lose data silently. "auto_prune": False, "retention_days": 7, "delete_orphans": True, "min_interval_hours": 24, }, # Maximum characters returned by a single read_file call. Reads that # exceed this are rejected with guidance to use offset+limit. # 100K chars ≈ 25–35K tokens across typical tokenisers. "file_read_max_chars": 100_000, # Tool-output truncation thresholds. When terminal output or a # single read_file page exceeds these limits, Hermes truncates the # payload sent to the model (keeping head + tail for terminal, # enforcing pagination for read_file). Tuning these trades context # footprint against how much raw output the model can see in one # shot. Ported from anomalyco/opencode PR #23770. # # - max_bytes: terminal_tool output cap, in chars # (default 50_000 ≈ 12-15K tokens). # - max_lines: read_file pagination cap — the maximum `limit` # a single read_file call can request before # being clamped (default 2000). # - max_line_length: per-line cap applied when read_file emits a # line-numbered view (default 2000 chars). "tool_output": { "max_bytes": 50_000, "max_lines": 2000, "max_line_length": 2000, }, "compression": { "enabled": True, "threshold": 0.50, # compress when context usage exceeds this ratio "target_ratio": 0.20, # fraction of threshold to preserve as recent tail "protect_last_n": 20, # minimum recent messages to keep uncompressed "hygiene_hard_message_limit": 400, # gateway session-hygiene force-compress threshold by message count }, # Anthropic prompt caching (Claude via OpenRouter or native Anthropic API). # cache_ttl must be "5m" or "1h" (Anthropic-supported tiers); other values are ignored. "prompt_caching": { "cache_ttl": "5m", }, # AWS Bedrock provider configuration. # Only used when model.provider is "bedrock". "bedrock": { "region": "", # AWS region for Bedrock API calls (empty = AWS_REGION env var → us-east-1) "discovery": { "enabled": True, # Auto-discover models via ListFoundationModels "provider_filter": [], # Only show models from these providers (e.g. ["anthropic", "amazon"]) "refresh_interval": 3600, # Cache discovery results for this many seconds }, "guardrail": { # Amazon Bedrock Guardrails — content filtering and safety policies. # Create a guardrail in the Bedrock console, then set the ID and version here. # See: https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails.html "guardrail_identifier": "", # e.g. "abc123def456" "guardrail_version": "", # e.g. "1" or "DRAFT" "stream_processing_mode": "async", # "sync" or "async" "trace": "disabled", # "enabled", "disabled", or "enabled_full" }, }, # Auxiliary model config — provider:model for each side task. # Format: provider is the provider name, model is the model slug. # "auto" for provider = auto-detect best available provider. # Empty model = use provider's default auxiliary model. # All tasks fall back to openrouter:google/gemini-3-flash-preview if # the configured provider is unavailable. "auxiliary": { "vision": { "provider": "auto", # auto | openrouter | nous | codex | custom "model": "", # e.g. "google/gemini-2.5-flash", "gpt-4o" "base_url": "", # direct OpenAI-compatible endpoint (takes precedence over provider) "api_key": "", # API key for base_url (falls back to OPENAI_API_KEY) "timeout": 120, # seconds — LLM API call timeout; vision payloads need generous timeout "extra_body": {}, # OpenAI-compatible provider-specific request fields "download_timeout": 30, # seconds — image HTTP download timeout; increase for slow connections }, "web_extract": { "provider": "auto", "model": "", "base_url": "", "api_key": "", "timeout": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models "extra_body": {}, }, "compression": { "provider": "auto", "model": "", "base_url": "", "api_key": "", "timeout": 120, # seconds — compression summarises large contexts; increase for local models "extra_body": {}, }, "session_search": { "provider": "auto", "model": "", "base_url": "", "api_key": "", "timeout": 30, "extra_body": {}, "max_concurrency": 3, # Clamp parallel summaries to avoid request-burst 429s on small providers }, "skills_hub": { "provider": "auto", "model": "", "base_url": "", "api_key": "", "timeout": 30, "extra_body": {}, }, "approval": { "provider": "auto", "model": "", # fast/cheap model recommended (e.g. gemini-flash, haiku) "base_url": "", "api_key": "", "timeout": 30, "extra_body": {}, }, "mcp": { "provider": "auto", "model": "", "base_url": "", "api_key": "", "timeout": 30, "extra_body": {}, }, "title_generation": { "provider": "auto", "model": "", "base_url": "", "api_key": "", "timeout": 30, "extra_body": {}, }, }, "display": { "compact": False, "personality": "kawaii", "resume_display": "full", "busy_input_mode": "interrupt", # interrupt | queue | steer # When true, `hermes --tui` auto-resumes the most recent human- # facing session on launch instead of forging a fresh one. # Mirrors `hermes -c` muscle memory. Default off so existing # users aren't surprised. HERMES_TUI_RESUME= always wins. "tui_auto_resume_recent": False, "bell_on_complete": False, "show_reasoning": False, "streaming": False, "final_response_markdown": "strip", # render | strip | raw "inline_diffs": True, # Show inline diff previews for write actions (write_file, patch, skill_manage) "show_cost": False, # Show $ cost in the status bar (off by default) "skin": "default", # TUI busy indicator style: kaomoji (default), emoji, unicode (braille # spinner), or ascii. Live-swappable via `/indicator