Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions gateway/platforms/api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1027,18 +1027,23 @@ def _create_agent(
— matching the semantics of the native gateway's ``session_key``.
"""
from run_agent import AIAgent
from gateway.run import _resolve_runtime_agent_kwargs, _resolve_gateway_model, _load_gateway_config, GatewayRunner
from gateway.run import (
GatewayRunner,
_load_gateway_config,
_resolve_gateway_max_iterations,
_resolve_gateway_model,
_resolve_runtime_agent_kwargs,
)
from hermes_cli.tools_config import _get_platform_tools

max_iterations = _resolve_gateway_max_iterations(reload_runtime_env=True)
runtime_kwargs = _resolve_runtime_agent_kwargs()
reasoning_config = GatewayRunner._load_reasoning_config()
model = _resolve_gateway_model()

user_config = _load_gateway_config()
enabled_toolsets = sorted(_get_platform_tools(user_config, "api_server"))

max_iterations = int(os.getenv("HERMES_MAX_ITERATIONS", "90"))

# Load fallback provider chain so the API server platform has the
# same fallback behaviour as Telegram/Discord/Slack (fixes #4954).
fallback_model = GatewayRunner._load_fallback_model()
Expand Down
77 changes: 53 additions & 24 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -910,6 +910,29 @@ def _clear_planned_restart_notification() -> None:
load_hermes_dotenv(hermes_home=_hermes_home, project_env=Path(__file__).resolve().parents[1] / '.env')


def _read_config_max_iterations() -> int | None:
"""Return config.yaml's authoritative agent.max_turns value when present."""
config_path = _hermes_home / 'config.yaml'
if not config_path.exists():
return None
try:
import yaml as _yaml
with open(config_path, encoding="utf-8") as f:
cfg = _yaml.safe_load(f) or {}
from hermes_cli.config import _expand_env_vars
cfg = _expand_env_vars(cfg)
except Exception:
return None

agent_cfg = cfg.get("agent", {})
if isinstance(agent_cfg, dict) and agent_cfg.get("max_turns") is not None:
return int(agent_cfg["max_turns"])
# Legacy root-level max_turns compatibility. Nested agent.max_turns wins.
if cfg.get("max_turns") is not None:
return int(cfg["max_turns"])
return None


def _reload_runtime_env_preserving_config_authority() -> None:
"""Reload .env for fresh credentials without letting stale .env override config.

Expand All @@ -923,21 +946,30 @@ def _reload_runtime_env_preserving_config_authority() -> None:
project_env=Path(__file__).resolve().parents[1] / '.env',
)

config_path = _hermes_home / 'config.yaml'
if not config_path.exists():
return
try:
import yaml as _yaml
with open(config_path, encoding="utf-8") as f:
cfg = _yaml.safe_load(f) or {}
from hermes_cli.config import _expand_env_vars
cfg = _expand_env_vars(cfg)
except Exception:
return
max_iterations = _read_config_max_iterations()
if max_iterations is not None:
os.environ["HERMES_MAX_ITERATIONS"] = str(max_iterations)

agent_cfg = cfg.get("agent", {})
if isinstance(agent_cfg, dict) and "max_turns" in agent_cfg:
os.environ["HERMES_MAX_ITERATIONS"] = str(agent_cfg["max_turns"])

def _resolve_gateway_max_iterations(
default: int = 90,
*,
reload_runtime_env: bool = False,
) -> int:
"""Resolve the per-agent iteration cap with config.yaml as source of truth.

``~/.hermes/.env`` may contain stale ``HERMES_MAX_ITERATIONS`` values from
older setup flows. Always prefer config.yaml ``agent.max_turns`` when it is
present; fall back to the environment only when config omits the key.
"""
if reload_runtime_env:
_reload_runtime_env_preserving_config_authority()

max_iterations = _read_config_max_iterations()
if max_iterations is not None:
os.environ["HERMES_MAX_ITERATIONS"] = str(max_iterations)
return max_iterations
return int(os.getenv("HERMES_MAX_ITERATIONS", str(default)))


_DOCKER_VOLUME_SPEC_RE = re.compile(r"^(?P<host>.+):(?P<container>/[^:]+?)(?::(?P<options>[^:]+))?$")
Expand Down Expand Up @@ -4507,7 +4539,7 @@ async def start(self) -> bool:
# config.yaml → env bridge did the right thing at a glance (instead
# of silently running at a stale .env value for weeks).
try:
_effective_max_iter = int(os.getenv("HERMES_MAX_ITERATIONS", "90"))
_effective_max_iter = _resolve_gateway_max_iterations()
logger.info(
"Agent budget: max_iterations=%d (agent.max_turns from config.yaml, "
"or HERMES_MAX_ITERATIONS from .env, or default 90)",
Expand Down Expand Up @@ -9841,7 +9873,7 @@ async def _run_background_task(
disabled_toolsets = agent_cfg.get("disabled_toolsets") or None

pr = self._provider_routing
max_iterations = int(os.getenv("HERMES_MAX_ITERATIONS", "90"))
max_iterations = _resolve_gateway_max_iterations(reload_runtime_env=True)
reasoning_config = self._resolve_session_reasoning_config(source=source)
self._reasoning_config = reasoning_config
self._service_tier = self._load_service_tier()
Expand Down Expand Up @@ -13598,9 +13630,11 @@ def run_sync():
# (concurrency-safe). Keep os.environ as fallback for CLI/cron.
os.environ["HERMES_SESSION_KEY"] = session_key or ""

# Read from env var or use default (same as CLI)
max_iterations = int(os.getenv("HERMES_MAX_ITERATIONS", "90"))

# Re-read .env and config for fresh credentials (gateway is long-lived,
# keys may change without restart). Resolve the budget after that reload
# so config.yaml agent.max_turns remains authoritative over stale .env.
max_iterations = _resolve_gateway_max_iterations(reload_runtime_env=True)

# Map platform enum to the platform hint key the agent understands.
# Platform.LOCAL ("local") maps to "cli"; others pass through as-is.
platform_key = "cli" if source.platform == Platform.LOCAL else source.platform.value
Expand All @@ -13614,11 +13648,6 @@ def run_sync():
if self._ephemeral_system_prompt:
combined_ephemeral = (combined_ephemeral + "\n\n" + self._ephemeral_system_prompt).strip()

# Re-read .env and config for fresh credentials (gateway is long-lived,
# keys may change without restart). Keep config.yaml authoritative for
# runtime budget settings bridged into env vars.
_reload_runtime_env_preserving_config_authority()

try:
model, runtime_kwargs = self._resolve_session_agent_runtime(
source=source,
Expand Down
60 changes: 60 additions & 0 deletions tests/gateway/test_runtime_env_reload_config_authority.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,63 @@ def test_reload_runtime_env_keeps_env_max_iterations_when_config_omits_key(
gateway_run._reload_runtime_env_preserving_config_authority()

assert os.environ["HERMES_MAX_ITERATIONS"] == "123"


def test_resolve_gateway_max_iterations_prefers_config_after_runtime_env_reload(
tmp_path: Path, monkeypatch
) -> None:
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
yaml.safe_dump({"agent": {"max_turns": 300}}),
encoding="utf-8",
)
(hermes_home / ".env").write_text("HERMES_MAX_ITERATIONS=90\n", encoding="utf-8")

monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
monkeypatch.setenv("HERMES_MAX_ITERATIONS", "90")

max_iterations = gateway_run._resolve_gateway_max_iterations(reload_runtime_env=True)

assert max_iterations == 300
assert os.environ["HERMES_MAX_ITERATIONS"] == "300"


def test_api_server_agent_uses_config_authoritative_max_iterations(
tmp_path: Path, monkeypatch
) -> None:
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
yaml.safe_dump({"agent": {"max_turns": 300}, "platform_toolsets": {"api_server": []}}),
encoding="utf-8",
)
(hermes_home / ".env").write_text("HERMES_MAX_ITERATIONS=90\n", encoding="utf-8")

monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
monkeypatch.setenv("HERMES_MAX_ITERATIONS", "90")
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {})
monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda: "test-model")
monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {"platform_toolsets": {"api_server": []}})
monkeypatch.setattr(gateway_run.GatewayRunner, "_load_reasoning_config", staticmethod(lambda: None))
monkeypatch.setattr(gateway_run.GatewayRunner, "_load_fallback_model", staticmethod(lambda: None))

import run_agent
from gateway.config import PlatformConfig
from gateway.platforms.api_server import APIServerAdapter

captured: dict[str, object] = {}

class FakeAgent:
def __init__(self, **kwargs):
captured.update(kwargs)

monkeypatch.setattr(run_agent, "AIAgent", FakeAgent)
adapter = APIServerAdapter(PlatformConfig(enabled=True, extra={}))
monkeypatch.setattr(adapter, "_ensure_session_db", lambda: None)

agent = adapter._create_agent(session_id="test-session")

assert isinstance(agent, FakeAgent)
assert captured["max_iterations"] == 300
assert os.environ["HERMES_MAX_ITERATIONS"] == "300"
Loading