mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix(config): reload .env over stale shell overrides
Hermes startup entrypoints now load ~/.hermes/.env and project fallback env files with user config taking precedence over stale shell-exported values. This makes model/provider/base URL changes in .env actually take effect after restarting Hermes. Adds a shared env loader plus regression coverage, and reproduces the original bug case where OPENAI_BASE_URL and HERMES_INFERENCE_PROVIDER remained stuck on old shell values before import.
This commit is contained in:
parent
463239ed85
commit
f24c00a5bf
8 changed files with 150 additions and 72 deletions
|
|
@ -42,19 +42,16 @@ def _setup_logging() -> None:
|
||||||
|
|
||||||
def _load_env() -> None:
|
def _load_env() -> None:
|
||||||
"""Load .env from HERMES_HOME (default ``~/.hermes``)."""
|
"""Load .env from HERMES_HOME (default ``~/.hermes``)."""
|
||||||
from dotenv import load_dotenv
|
from hermes_cli.env_loader import load_hermes_dotenv
|
||||||
|
|
||||||
hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||||
env_file = hermes_home / ".env"
|
loaded = load_hermes_dotenv(hermes_home=hermes_home)
|
||||||
if env_file.exists():
|
if loaded:
|
||||||
try:
|
for env_file in loaded:
|
||||||
load_dotenv(dotenv_path=env_file, encoding="utf-8")
|
logging.getLogger(__name__).info("Loaded env from %s", env_file)
|
||||||
except UnicodeDecodeError:
|
|
||||||
load_dotenv(dotenv_path=env_file, encoding="latin-1")
|
|
||||||
logging.getLogger(__name__).info("Loaded env from %s", env_file)
|
|
||||||
else:
|
else:
|
||||||
logging.getLogger(__name__).info(
|
logging.getLogger(__name__).info(
|
||||||
"No .env found at %s, using system env", env_file
|
"No .env found at %s, using system env", hermes_home / ".env"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
17
cli.py
17
cli.py
|
|
@ -61,23 +61,14 @@ import queue
|
||||||
_COMMAND_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
|
_COMMAND_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
|
||||||
|
|
||||||
|
|
||||||
# Load .env from ~/.hermes/.env first, then project root as dev fallback
|
# Load .env from ~/.hermes/.env first, then project root as dev fallback.
|
||||||
from dotenv import load_dotenv
|
# User-managed env files should override stale shell exports on restart.
|
||||||
from hermes_constants import OPENROUTER_BASE_URL
|
from hermes_constants import OPENROUTER_BASE_URL
|
||||||
|
from hermes_cli.env_loader import load_hermes_dotenv
|
||||||
|
|
||||||
_hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
_hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||||
_user_env = _hermes_home / ".env"
|
|
||||||
_project_env = Path(__file__).parent / '.env'
|
_project_env = Path(__file__).parent / '.env'
|
||||||
if _user_env.exists():
|
load_hermes_dotenv(hermes_home=_hermes_home, project_env=_project_env)
|
||||||
try:
|
|
||||||
load_dotenv(dotenv_path=_user_env, encoding="utf-8")
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
load_dotenv(dotenv_path=_user_env, encoding="latin-1")
|
|
||||||
elif _project_env.exists():
|
|
||||||
try:
|
|
||||||
load_dotenv(dotenv_path=_project_env, encoding="utf-8")
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
load_dotenv(dotenv_path=_project_env, encoding="latin-1")
|
|
||||||
|
|
||||||
# Point mini-swe-agent at ~/.hermes/ so it shares our config
|
# Point mini-swe-agent at ~/.hermes/ so it shares our config
|
||||||
os.environ.setdefault("MSWEA_GLOBAL_CONFIG_DIR", str(_hermes_home))
|
os.environ.setdefault("MSWEA_GLOBAL_CONFIG_DIR", str(_hermes_home))
|
||||||
|
|
|
||||||
|
|
@ -35,16 +35,12 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
# Resolve Hermes home directory (respects HERMES_HOME override)
|
# Resolve Hermes home directory (respects HERMES_HOME override)
|
||||||
_hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
_hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||||
|
|
||||||
# Load environment variables from ~/.hermes/.env first
|
# Load environment variables from ~/.hermes/.env first.
|
||||||
from dotenv import load_dotenv
|
# User-managed env files should override stale shell exports on restart.
|
||||||
|
from dotenv import load_dotenv # backward-compat for tests that monkeypatch this symbol
|
||||||
|
from hermes_cli.env_loader import load_hermes_dotenv
|
||||||
_env_path = _hermes_home / '.env'
|
_env_path = _hermes_home / '.env'
|
||||||
if _env_path.exists():
|
load_hermes_dotenv(hermes_home=_hermes_home, project_env=Path(__file__).resolve().parents[1] / '.env')
|
||||||
try:
|
|
||||||
load_dotenv(_env_path, encoding="utf-8")
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
load_dotenv(_env_path, encoding="latin-1")
|
|
||||||
# Also try project .env as fallback
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
# Bridge config.yaml values into the environment so os.getenv() picks them up.
|
# Bridge config.yaml values into the environment so os.getenv() picks them up.
|
||||||
# config.yaml is authoritative for terminal settings — overrides .env.
|
# config.yaml is authoritative for terminal settings — overrides .env.
|
||||||
|
|
|
||||||
46
hermes_cli/env_loader.py
Normal file
46
hermes_cli/env_loader.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
"""Helpers for loading Hermes .env files consistently across entrypoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
|
||||||
|
def _load_dotenv_with_fallback(path: Path, *, override: bool) -> None:
|
||||||
|
try:
|
||||||
|
load_dotenv(dotenv_path=path, override=override, encoding="utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
load_dotenv(dotenv_path=path, override=override, encoding="latin-1")
|
||||||
|
|
||||||
|
|
||||||
|
def load_hermes_dotenv(
|
||||||
|
*,
|
||||||
|
hermes_home: str | os.PathLike | None = None,
|
||||||
|
project_env: str | os.PathLike | None = None,
|
||||||
|
) -> list[Path]:
|
||||||
|
"""Load Hermes environment files with user config taking precedence.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- `~/.hermes/.env` overrides stale shell-exported values when present.
|
||||||
|
- project `.env` acts as a dev fallback and only fills missing values when
|
||||||
|
the user env exists.
|
||||||
|
- if no user env exists, the project `.env` also overrides stale shell vars.
|
||||||
|
"""
|
||||||
|
loaded: list[Path] = []
|
||||||
|
|
||||||
|
home_path = Path(hermes_home or os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||||
|
user_env = home_path / ".env"
|
||||||
|
project_env_path = Path(project_env) if project_env else None
|
||||||
|
|
||||||
|
if user_env.exists():
|
||||||
|
_load_dotenv_with_fallback(user_env, override=True)
|
||||||
|
loaded.append(user_env)
|
||||||
|
|
||||||
|
if project_env_path and project_env_path.exists():
|
||||||
|
_load_dotenv_with_fallback(project_env_path, override=not loaded)
|
||||||
|
loaded.append(project_env_path)
|
||||||
|
|
||||||
|
return loaded
|
||||||
|
|
@ -54,16 +54,11 @@ from typing import Optional
|
||||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
# Load .env from ~/.hermes/.env first, then project root as dev fallback
|
# Load .env from ~/.hermes/.env first, then project root as dev fallback.
|
||||||
from dotenv import load_dotenv
|
# User-managed env files should override stale shell exports on restart.
|
||||||
from hermes_cli.config import get_env_path, get_hermes_home
|
from hermes_cli.config import get_hermes_home
|
||||||
_user_env = get_env_path()
|
from hermes_cli.env_loader import load_hermes_dotenv
|
||||||
if _user_env.exists():
|
load_hermes_dotenv(project_env=PROJECT_ROOT / '.env')
|
||||||
try:
|
|
||||||
load_dotenv(dotenv_path=_user_env, encoding="utf-8")
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
load_dotenv(dotenv_path=_user_env, encoding="latin-1")
|
|
||||||
load_dotenv(dotenv_path=PROJECT_ROOT / '.env', override=False)
|
|
||||||
|
|
||||||
# Point mini-swe-agent at ~/.hermes/ so it shares our config
|
# Point mini-swe-agent at ~/.hermes/ so it shares our config
|
||||||
os.environ.setdefault("MSWEA_GLOBAL_CONFIG_DIR", str(get_hermes_home()))
|
os.environ.setdefault("MSWEA_GLOBAL_CONFIG_DIR", str(get_hermes_home()))
|
||||||
|
|
|
||||||
23
rl_cli.py
23
rl_cli.py
|
|
@ -27,25 +27,16 @@ from pathlib import Path
|
||||||
import fire
|
import fire
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
# Load .env from ~/.hermes/.env first, then project root as dev fallback
|
# Load .env from ~/.hermes/.env first, then project root as dev fallback.
|
||||||
from dotenv import load_dotenv
|
# User-managed env files should override stale shell exports on restart.
|
||||||
|
|
||||||
_hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
_hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||||
_user_env = _hermes_home / ".env"
|
|
||||||
_project_env = Path(__file__).parent / '.env'
|
_project_env = Path(__file__).parent / '.env'
|
||||||
|
|
||||||
if _user_env.exists():
|
from hermes_cli.env_loader import load_hermes_dotenv
|
||||||
try:
|
|
||||||
load_dotenv(dotenv_path=_user_env, encoding="utf-8")
|
_loaded_env_paths = load_hermes_dotenv(hermes_home=_hermes_home, project_env=_project_env)
|
||||||
except UnicodeDecodeError:
|
for _env_path in _loaded_env_paths:
|
||||||
load_dotenv(dotenv_path=_user_env, encoding="latin-1")
|
print(f"✅ Loaded environment variables from {_env_path}")
|
||||||
print(f"✅ Loaded environment variables from {_user_env}")
|
|
||||||
elif _project_env.exists():
|
|
||||||
try:
|
|
||||||
load_dotenv(dotenv_path=_project_env, encoding="utf-8")
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
load_dotenv(dotenv_path=_project_env, encoding="latin-1")
|
|
||||||
print(f"✅ Loaded environment variables from {_project_env}")
|
|
||||||
|
|
||||||
# Set terminal working directory to tinker-atropos submodule
|
# Set terminal working directory to tinker-atropos submodule
|
||||||
# This ensures terminal commands run in the right context for RL work
|
# This ensures terminal commands run in the right context for RL work
|
||||||
|
|
|
||||||
22
run_agent.py
22
run_agent.py
|
|
@ -45,24 +45,16 @@ import fire
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Load .env from ~/.hermes/.env first, then project root as dev fallback
|
# Load .env from ~/.hermes/.env first, then project root as dev fallback.
|
||||||
from dotenv import load_dotenv
|
# User-managed env files should override stale shell exports on restart.
|
||||||
|
from hermes_cli.env_loader import load_hermes_dotenv
|
||||||
|
|
||||||
_hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
_hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||||
_user_env = _hermes_home / ".env"
|
|
||||||
_project_env = Path(__file__).parent / '.env'
|
_project_env = Path(__file__).parent / '.env'
|
||||||
if _user_env.exists():
|
_loaded_env_paths = load_hermes_dotenv(hermes_home=_hermes_home, project_env=_project_env)
|
||||||
try:
|
if _loaded_env_paths:
|
||||||
load_dotenv(dotenv_path=_user_env, encoding="utf-8")
|
for _env_path in _loaded_env_paths:
|
||||||
except UnicodeDecodeError:
|
logger.info("Loaded environment variables from %s", _env_path)
|
||||||
load_dotenv(dotenv_path=_user_env, encoding="latin-1")
|
|
||||||
logger.info("Loaded environment variables from %s", _user_env)
|
|
||||||
elif _project_env.exists():
|
|
||||||
try:
|
|
||||||
load_dotenv(dotenv_path=_project_env, encoding="utf-8")
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
load_dotenv(dotenv_path=_project_env, encoding="latin-1")
|
|
||||||
logger.info("Loaded environment variables from %s", _project_env)
|
|
||||||
else:
|
else:
|
||||||
logger.info("No .env file found. Using system environment variables.")
|
logger.info("No .env file found. Using system environment variables.")
|
||||||
|
|
||||||
|
|
|
||||||
70
tests/hermes_cli/test_env_loader.py
Normal file
70
tests/hermes_cli/test_env_loader.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import importlib
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from hermes_cli.env_loader import load_hermes_dotenv
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_env_overrides_stale_shell_values(tmp_path, monkeypatch):
|
||||||
|
home = tmp_path / "hermes"
|
||||||
|
home.mkdir()
|
||||||
|
env_file = home / ".env"
|
||||||
|
env_file.write_text("OPENAI_BASE_URL=https://new.example/v1\n", encoding="utf-8")
|
||||||
|
|
||||||
|
monkeypatch.setenv("OPENAI_BASE_URL", "https://old.example/v1")
|
||||||
|
|
||||||
|
loaded = load_hermes_dotenv(hermes_home=home)
|
||||||
|
|
||||||
|
assert loaded == [env_file]
|
||||||
|
assert os.getenv("OPENAI_BASE_URL") == "https://new.example/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_env_overrides_stale_shell_values_when_user_env_missing(tmp_path, monkeypatch):
|
||||||
|
home = tmp_path / "hermes"
|
||||||
|
project_env = tmp_path / ".env"
|
||||||
|
project_env.write_text("OPENAI_BASE_URL=https://project.example/v1\n", encoding="utf-8")
|
||||||
|
|
||||||
|
monkeypatch.setenv("OPENAI_BASE_URL", "https://old.example/v1")
|
||||||
|
|
||||||
|
loaded = load_hermes_dotenv(hermes_home=home, project_env=project_env)
|
||||||
|
|
||||||
|
assert loaded == [project_env]
|
||||||
|
assert os.getenv("OPENAI_BASE_URL") == "https://project.example/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_env_takes_precedence_over_project_env(tmp_path, monkeypatch):
|
||||||
|
home = tmp_path / "hermes"
|
||||||
|
home.mkdir()
|
||||||
|
user_env = home / ".env"
|
||||||
|
project_env = tmp_path / ".env"
|
||||||
|
user_env.write_text("OPENAI_BASE_URL=https://user.example/v1\n", encoding="utf-8")
|
||||||
|
project_env.write_text("OPENAI_BASE_URL=https://project.example/v1\nOPENAI_API_KEY=project-key\n", encoding="utf-8")
|
||||||
|
|
||||||
|
monkeypatch.setenv("OPENAI_BASE_URL", "https://old.example/v1")
|
||||||
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||||
|
|
||||||
|
loaded = load_hermes_dotenv(hermes_home=home, project_env=project_env)
|
||||||
|
|
||||||
|
assert loaded == [user_env, project_env]
|
||||||
|
assert os.getenv("OPENAI_BASE_URL") == "https://user.example/v1"
|
||||||
|
assert os.getenv("OPENAI_API_KEY") == "project-key"
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_import_applies_user_env_over_shell_values(tmp_path, monkeypatch):
|
||||||
|
home = tmp_path / "hermes"
|
||||||
|
home.mkdir()
|
||||||
|
(home / ".env").write_text(
|
||||||
|
"OPENAI_BASE_URL=https://new.example/v1\nHERMES_INFERENCE_PROVIDER=custom\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||||
|
monkeypatch.setenv("OPENAI_BASE_URL", "https://old.example/v1")
|
||||||
|
monkeypatch.setenv("HERMES_INFERENCE_PROVIDER", "openrouter")
|
||||||
|
|
||||||
|
sys.modules.pop("hermes_cli.main", None)
|
||||||
|
importlib.import_module("hermes_cli.main")
|
||||||
|
|
||||||
|
assert os.getenv("OPENAI_BASE_URL") == "https://new.example/v1"
|
||||||
|
assert os.getenv("HERMES_INFERENCE_PROVIDER") == "custom"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue