Skip to content
Merged
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
10 changes: 8 additions & 2 deletions gateway/platforms/api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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).
Expand Down
22 changes: 14 additions & 8 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<host>.+):(?P<container>/[^:]+?)(?::(?P<options>[^:]+))?$")
_DOCKER_MEDIA_OUTPUT_CONTAINER_PATHS = {"/output", "/outputs"}

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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.
Expand All @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
34 changes: 34 additions & 0 deletions tests/gateway/test_api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions tests/gateway/test_cached_agent_max_iterations.py
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 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,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
Loading