Merge remote-tracking branch 'origin/main' into bb/gui
# Conflicts: # apps/dashboard/src/i18n/af.ts # apps/dashboard/src/i18n/de.ts # apps/dashboard/src/i18n/es.ts # apps/dashboard/src/i18n/fr.ts # apps/dashboard/src/i18n/ga.ts # apps/dashboard/src/i18n/hu.ts # apps/dashboard/src/i18n/it.ts # apps/dashboard/src/i18n/ja.ts # apps/dashboard/src/i18n/ko.ts # apps/dashboard/src/i18n/pt.ts # apps/dashboard/src/i18n/ru.ts # apps/dashboard/src/i18n/tr.ts # apps/dashboard/src/i18n/uk.ts # apps/dashboard/src/i18n/zh-hant.ts # gateway/config.py # hermes_cli/main.py # plugins/strike-freedom-cockpit/README.md # tui_gateway/server.py
This commit is contained in:
+65
-13
@@ -28,6 +28,48 @@ from typing import Dict, Any, Optional, List, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Track which (config_path, mtime_ns, size) tuples we've already warned about
|
||||
# so concurrent CLI/gateway loads of a broken config.yaml don't spam stderr
|
||||
# every time. Cleared automatically when the file changes (different mtime).
|
||||
_CONFIG_PARSE_WARNED: set = set()
|
||||
|
||||
|
||||
def _warn_config_parse_failure(config_path: Path, exc: Exception) -> None:
|
||||
"""Surface a config.yaml parse failure to user, log, and stderr.
|
||||
|
||||
A YAML parse error in ``~/.hermes/config.yaml`` causes ``load_config()``
|
||||
to silently fall back to ``DEFAULT_CONFIG``, which means every user
|
||||
override (auxiliary providers, fallback chain, model overrides, etc.)
|
||||
is dropped. Before this helper that was a one-line ``print(...)`` that
|
||||
scrolled off-screen on the first invocation and was never seen again.
|
||||
|
||||
Now: warn once per (path, mtime_ns, size) on stderr **and** in
|
||||
``agent.log`` / ``errors.log`` at WARNING level so ``hermes logs``
|
||||
surfaces it. Re-warns automatically if the file changes (different
|
||||
mtime/size), so users editing the config see the next failure.
|
||||
"""
|
||||
try:
|
||||
st = config_path.stat()
|
||||
key = (str(config_path), st.st_mtime_ns, st.st_size)
|
||||
except OSError:
|
||||
key = (str(config_path), 0, 0)
|
||||
if key in _CONFIG_PARSE_WARNED:
|
||||
return
|
||||
_CONFIG_PARSE_WARNED.add(key)
|
||||
|
||||
msg = (
|
||||
f"Failed to parse {config_path}: {exc}. "
|
||||
f"Falling back to default config — every user override "
|
||||
f"(auxiliary providers, fallback chain, model settings) is being IGNORED. "
|
||||
f"Fix the YAML and restart."
|
||||
)
|
||||
logger.warning(msg)
|
||||
try:
|
||||
sys.stderr.write(f"⚠️ hermes config: {msg}\n")
|
||||
sys.stderr.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_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] = {}
|
||||
@@ -537,6 +579,7 @@ DEFAULT_CONFIG = {
|
||||
# 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,
|
||||
"docker_extra_args": [], # Extra flags passed verbatim to docker run
|
||||
# Explicit opt-in: run the Docker container as the host user's uid:gid
|
||||
# (via `--user`). When enabled, files written into bind-mounted dirs
|
||||
# (docker_volumes, the persistent workspace, or the auto-mounted cwd)
|
||||
@@ -680,8 +723,15 @@ DEFAULT_CONFIG = {
|
||||
|
||||
# Anthropic prompt caching (Claude via OpenRouter or native Anthropic API).
|
||||
# cache_ttl must be "5m" or "1h" (Anthropic-supported tiers); other values are ignored.
|
||||
# long_lived_prefix: when true (default), Claude on Anthropic / OpenRouter / Nous
|
||||
# Portal uses a split layout: tools[-1] + stable system prefix at long_lived_ttl
|
||||
# (cross-session cache), last 2 messages at cache_ttl (within-session rolling).
|
||||
# Set false to keep the legacy "system + last 3 messages" single-tier layout.
|
||||
# long_lived_ttl: TTL for the cross-session prefix tier ("5m" or "1h"; default "1h").
|
||||
"prompt_caching": {
|
||||
"cache_ttl": "5m",
|
||||
"long_lived_prefix": True,
|
||||
"long_lived_ttl": "1h",
|
||||
},
|
||||
|
||||
# OpenRouter-specific settings.
|
||||
@@ -859,6 +909,7 @@ DEFAULT_CONFIG = {
|
||||
"bell_on_complete": False,
|
||||
"show_reasoning": False,
|
||||
"streaming": False,
|
||||
"timestamps": False, # Show [HH:MM] on user and assistant labels
|
||||
"final_response_markdown": "strip", # render | strip | raw
|
||||
# Preserve recent classic CLI output across Ctrl+L, /redraw, and
|
||||
# terminal resize full-screen clears. Disable if a terminal emulator
|
||||
@@ -3165,7 +3216,7 @@ def warn_deprecated_cwd_env_vars(config: Optional[Dict[str, Any]] = None) -> Non
|
||||
terminal_cfg = config.get("terminal", {})
|
||||
config_cwd = terminal_cfg.get("cwd", ".") if isinstance(terminal_cfg, dict) else "."
|
||||
# Only warn if config.yaml doesn't have an explicit path
|
||||
config_has_explicit_cwd = config_cwd not in (".", "auto", "cwd", "")
|
||||
config_has_explicit_cwd = config_cwd not in {".", "auto", "cwd", ""}
|
||||
|
||||
lines: list[str] = []
|
||||
if messaging_cwd:
|
||||
@@ -3225,10 +3276,10 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
|
||||
if "tool_progress" not in display:
|
||||
old_enabled = get_env_value("HERMES_TOOL_PROGRESS")
|
||||
old_mode = get_env_value("HERMES_TOOL_PROGRESS_MODE")
|
||||
if old_enabled and old_enabled.lower() in ("false", "0", "no"):
|
||||
if old_enabled and old_enabled.lower() in {"false", "0", "no"}:
|
||||
display["tool_progress"] = "off"
|
||||
results["config_added"].append("display.tool_progress=off (from HERMES_TOOL_PROGRESS=false)")
|
||||
elif old_mode and old_mode.lower() in ("new", "all"):
|
||||
elif old_mode and old_mode.lower() in {"new", "all"}:
|
||||
display["tool_progress"] = old_mode.lower()
|
||||
results["config_added"].append(f"display.tool_progress={old_mode.lower()} (from HERMES_TOOL_PROGRESS_MODE)")
|
||||
else:
|
||||
@@ -3307,7 +3358,7 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
|
||||
new_entry = {"api": old_url}
|
||||
if old_name:
|
||||
new_entry["name"] = old_name
|
||||
if old_key and old_key not in ("no-key", "no-key-required", ""):
|
||||
if old_key and old_key not in {"no-key", "no-key-required", ""}:
|
||||
new_entry["api_key"] = old_key
|
||||
|
||||
# Carry over model and api_mode if present
|
||||
@@ -3365,7 +3416,7 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
|
||||
stt.pop("model", None)
|
||||
# Place it in the appropriate provider section only if the
|
||||
# user didn't already set a model there
|
||||
if provider in ("local", "local_command"):
|
||||
if provider in {"local", "local_command"}:
|
||||
# Don't migrate an OpenAI model name into the local section
|
||||
_local_models = {
|
||||
"tiny.en", "tiny", "base.en", "base", "small.en", "small",
|
||||
@@ -3449,7 +3500,7 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
|
||||
if not aux_comp.get("model"):
|
||||
aux_comp["model"] = str(s_model).strip()
|
||||
migrated_keys.append(f"model={s_model}")
|
||||
if s_provider and str(s_provider).strip() not in ("", "auto"):
|
||||
if s_provider and str(s_provider).strip() not in {"", "auto"}:
|
||||
aux = config.setdefault("auxiliary", {})
|
||||
aux_comp = aux.setdefault("compression", {})
|
||||
if not aux_comp.get("provider") or aux_comp.get("provider") == "auto":
|
||||
@@ -3680,7 +3731,7 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
answer = "n"
|
||||
|
||||
if answer in ("y", "yes"):
|
||||
if answer in {"y", "yes"}:
|
||||
print()
|
||||
for name, info in new_and_unset:
|
||||
if info.get("url"):
|
||||
@@ -3741,7 +3792,7 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
answer = "n"
|
||||
|
||||
if answer in ("y", "yes"):
|
||||
if answer in {"y", "yes"}:
|
||||
print()
|
||||
config = load_config()
|
||||
try:
|
||||
@@ -4011,7 +4062,8 @@ def read_raw_config() -> Dict[str, Any]:
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
_warn_config_parse_failure(config_path, e)
|
||||
return {}
|
||||
|
||||
if not isinstance(data, dict):
|
||||
@@ -4061,7 +4113,7 @@ def load_config() -> Dict[str, Any]:
|
||||
|
||||
config = _deep_merge(config, user_config)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to load config: {e}")
|
||||
_warn_config_parse_failure(config_path, e)
|
||||
|
||||
normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))
|
||||
expanded = _expand_env_vars(normalized)
|
||||
@@ -4822,9 +4874,9 @@ def set_config_value(key: str, value: str):
|
||||
# inline navigation here silently overwrote lists with dicts.
|
||||
|
||||
# Convert value to appropriate type
|
||||
if value.lower() in ('true', 'yes', 'on'):
|
||||
if value.lower() in {'true', 'yes', 'on'}:
|
||||
value = True
|
||||
elif value.lower() in ('false', 'no', 'off'):
|
||||
elif value.lower() in {'false', 'no', 'off'}:
|
||||
value = False
|
||||
elif value.isdigit():
|
||||
value = int(value)
|
||||
@@ -5029,7 +5081,7 @@ def _inject_profile_env_vars() -> None:
|
||||
try:
|
||||
from providers import list_providers
|
||||
for _pp in list_providers():
|
||||
if _pp.auth_type not in ("api_key",):
|
||||
if _pp.auth_type not in {"api_key",}:
|
||||
continue
|
||||
for _var in _pp.env_vars:
|
||||
if _var in OPTIONAL_ENV_VARS:
|
||||
|
||||
Reference in New Issue
Block a user