diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index da86952a09d2..54720f2b3008 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -1033,7 +1033,13 @@ 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 ( + _current_max_iterations, + _resolve_runtime_agent_kwargs, + _resolve_gateway_model, + _load_gateway_config, + GatewayRunner, + ) from hermes_cli.tools_config import _get_platform_tools runtime_kwargs = _resolve_runtime_agent_kwargs() @@ -1043,7 +1049,7 @@ def _create_agent( user_config = _load_gateway_config() enabled_toolsets = sorted(_get_platform_tools(user_config, "api_server")) - max_iterations = int(os.getenv("HERMES_MAX_ITERATIONS", "90")) + max_iterations = _current_max_iterations() # Load fallback provider chain so the API server platform has the # same fallback behaviour as Telegram/Discord/Slack (fixes #4954). diff --git a/gateway/run.py b/gateway/run.py index e24afd035e7f..741f2a235ad1 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1196,6 +1196,15 @@ def _reload_runtime_env_preserving_config_authority() -> None: os.environ["HERMES_MAX_ITERATIONS"] = str(agent_cfg["max_turns"]) +def _current_max_iterations() -> int: + """Return the current per-turn iteration budget after runtime env refresh.""" + _reload_runtime_env_preserving_config_authority() + try: + return int(os.getenv("HERMES_MAX_ITERATIONS", "90")) + except (TypeError, ValueError): + return 90 + + _DOCKER_VOLUME_SPEC_RE = re.compile(r"^(?P.+):(?P/[^:]+?)(?::(?P[^:]+))?$") _DOCKER_MEDIA_OUTPUT_CONTAINER_PATHS = {"/output", "/outputs"} @@ -10633,7 +10642,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 = _current_max_iterations() reasoning_config = self._resolve_session_reasoning_config(source=source) self._reasoning_config = reasoning_config self._service_tier = self._load_service_tier() @@ -14581,9 +14590,6 @@ def run_sync(): # session_key is now set via contextvars in _set_session_env() # (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")) # Map platform enum to the platform hint key the agent understands. # Platform.LOCAL ("local") maps to "cli"; others pass through as-is. @@ -14598,10 +14604,7 @@ 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() + max_iterations = _current_max_iterations() try: model, runtime_kwargs = self._resolve_session_agent_runtime( @@ -14799,6 +14802,9 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None: except KeyError: pass self._init_cached_agent_for_turn(agent, _interrupt_depth) + # Refresh agent max_iterations from current config + # (cached agent may have been created with old config) + agent.max_iterations = max_iterations logger.debug("Reusing cached agent for session %s", session_key) if agent is None: diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index 95d49d8b4f14..ac5e29c4d3c7 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -337,6 +337,40 @@ def __init__(self, **kwargs): assert isinstance(agent, FakeAgent) assert captured["reasoning_config"] == {"enabled": True, "effort": "xhigh"} + def test_create_agent_refreshes_max_iterations_from_runtime_config(self, monkeypatch): + captured = {} + + class FakeAgent: + def __init__(self, **kwargs): + captured.update(kwargs) + + monkeypatch.setattr("run_agent.AIAgent", FakeAgent) + monkeypatch.setattr( + "gateway.run._resolve_runtime_agent_kwargs", + lambda: { + "provider": "openai", + "base_url": "https://example.test/v1", + "api_mode": "chat_completions", + }, + ) + monkeypatch.setattr("gateway.run._resolve_gateway_model", lambda: "gpt-5") + monkeypatch.setattr("gateway.run._load_gateway_config", lambda: {"agent": {"max_turns": 200}}) + monkeypatch.setattr( + "gateway.run.GatewayRunner._load_reasoning_config", + staticmethod(lambda: {}), + ) + monkeypatch.setattr("gateway.run.GatewayRunner._load_fallback_model", staticmethod(lambda: None)) + monkeypatch.setattr("gateway.run._current_max_iterations", lambda: 200) + monkeypatch.setattr("hermes_cli.tools_config._get_platform_tools", lambda *_: set()) + + adapter = APIServerAdapter(PlatformConfig(enabled=True)) + monkeypatch.setattr(adapter, "_ensure_session_db", lambda: None) + + agent = adapter._create_agent(session_id="api-session") + + assert isinstance(agent, FakeAgent) + assert captured["max_iterations"] == 200 + # --------------------------------------------------------------------------- # Auth checking diff --git a/tests/gateway/test_cached_agent_max_iterations.py b/tests/gateway/test_cached_agent_max_iterations.py new file mode 100644 index 000000000000..fcd523c70ef6 --- /dev/null +++ b/tests/gateway/test_cached_agent_max_iterations.py @@ -0,0 +1,92 @@ +"""Regression tests for PR #48127: cached agent max_iterations refresh. + +When a long-lived gateway reuses an agent from its cache, the agent must run +the *current* configured iteration budget — not the budget it was constructed +with on the first turn of that session. Two pieces make that true: + +1. ``GatewayRunner._init_cached_agent_for_turn`` must NOT reset + ``max_iterations`` itself (the gateway refreshes it explicitly right after, + from current config). If this helper ever started clobbering it, the + gateway's refresh would be silently undone. +2. The per-turn budget object is rebuilt from ``agent.max_iterations`` at the + start of every turn (``agent/turn_context.py`` -> ``IterationBudget``), so + refreshing ``max_iterations`` on the cached agent is sufficient to change + the operative cap the agent loop checks. + +These tests exercise the real code paths rather than asserting a plain +assignment, so they fail if either contract regresses. +""" + +import time +from types import SimpleNamespace + +from agent.iteration_budget import IterationBudget + + +def _make_cached_agent(max_iterations: int) -> SimpleNamespace: + """A minimal stand-in cached agent with the attributes the helpers touch.""" + # The turn loop checks both api_call_count >= max_iterations AND + # iteration_budget.remaining <= 0 (turn_finalizer.py), so the budget must + # also reflect the new cap. Seed it with the stale value to prove the + # refresh propagates. + return SimpleNamespace( + _last_activity_ts=time.time() - 1000, + _last_activity_desc="previous turn", + _api_call_count=42, + _last_flushed_db_idx=5, + max_iterations=max_iterations, + iteration_budget=IterationBudget(max_iterations), + ) + + +def test_init_cached_agent_for_turn_does_not_touch_max_iterations(): + """The per-turn reset helper must leave max_iterations untouched. + + The gateway refreshes max_iterations explicitly right after calling this + helper; if the helper ever reset it, that refresh would be undone. + """ + from gateway.run import GatewayRunner + + agent = _make_cached_agent(90) + GatewayRunner._init_cached_agent_for_turn(agent, interrupt_depth=0) + + # Per-turn state was reset... + assert agent._api_call_count == 0 + assert agent._last_activity_desc == "starting new turn (cached)" + assert agent._last_flushed_db_idx == 0 + # ...but the iteration budget was NOT changed by the helper itself. + assert agent.max_iterations == 90 + + +def test_init_cached_agent_preserves_max_iterations_on_interrupt_depth(): + """Interrupt-recursive turns must also leave max_iterations alone.""" + from gateway.run import GatewayRunner + + agent = _make_cached_agent(200) + GatewayRunner._init_cached_agent_for_turn(agent, interrupt_depth=1) + + # Activity timestamps preserved for the inactivity watchdog (#15654)... + assert agent._last_activity_desc == "previous turn" + # ...and max_iterations untouched. + assert agent.max_iterations == 200 + + +def test_refreshed_max_iterations_propagates_to_turn_budget(): + """Refreshing max_iterations on a cached agent changes the operative cap. + + The gateway sets ``agent.max_iterations = max_iterations`` on cache reuse; + the new turn's setup then rebuilds ``iteration_budget`` from it. This proves + the refresh actually moves the budget the agent loop enforces — the cached + agent started at 90 and ends a new turn capped at 200. + """ + agent = _make_cached_agent(90) + assert agent.iteration_budget.max_total == 90 + + # Gateway refresh on cache reuse: + agent.max_iterations = 200 + + # Start-of-turn budget rebuild (agent/turn_context.py:166): + agent.iteration_budget = IterationBudget(agent.max_iterations) + + assert agent.iteration_budget.max_total == 200 + assert agent.iteration_budget.remaining == 200 diff --git a/tests/gateway/test_runtime_env_reload_config_authority.py b/tests/gateway/test_runtime_env_reload_config_authority.py index 92d54b8863ce..d90b58297e88 100644 --- a/tests/gateway/test_runtime_env_reload_config_authority.py +++ b/tests/gateway/test_runtime_env_reload_config_authority.py @@ -51,3 +51,18 @@ 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_current_max_iterations_reloads_before_reading(monkeypatch) -> None: + monkeypatch.setenv("HERMES_MAX_ITERATIONS", "90") + + def _fake_reload() -> None: + os.environ["HERMES_MAX_ITERATIONS"] = "200" + + monkeypatch.setattr( + gateway_run, + "_reload_runtime_env_preserving_config_authority", + _fake_reload, + ) + + assert gateway_run._current_max_iterations() == 200