From 84d3da7230225f27195d311860869becbb77cfa2 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Wed, 17 Jun 2026 17:41:45 +0000 Subject: [PATCH] fix(#4362): gate gateway runs-API routing behind explicit opt-in The WebUI gateway chat backend routed every browser chat turn through the gateway's /v1/runs API whenever the gateway advertised approval/runs support (gateway_chat.py capability gate). The runs API creates an isolated Api_Server session per turn, so mid-conversation context was lost between messages. Routing through /v1/runs is now gated behind an explicit opt-in (_gateway_use_runs_api_enabled: HERMES_WEBUI_GATEWAY_USE_RUNS_API env var or webui_gateway_use_runs_api config key, env-over-config precedence, default off). The capability gate now requires BOTH the opt-in flag AND gateway approval support before using /v1/runs; otherwise normal chat stays on /v1/chat/completions, preserving the session via X-Hermes-Session-Id. Operators who want the in-gateway-chat approval-events flow re-enable it with the flag. Co-authored-by: Rod Boev --- CHANGELOG.md | 6 ++ api/gateway_chat.py | 15 ++++- tests/test_gateway_approval_runs_api.py | 4 +- tests/test_webui_gateway_chat_backend.py | 82 ++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31cd8884..d3dd16e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.477] — 2026-06-17 — Release QL (gateway runs-API routing now opt-in) + +### Fixed + +- **Gateway chat no longer silently loses mid-conversation context (#4362).** When the WebUI was connected to a Hermes gateway that advertised approval/runs support, it routed *every* browser chat turn through the gateway's `/v1/runs` API, which creates an isolated Api_Server session per turn — so the agent lost the conversation's context between messages. Routing through the runs API is now gated behind an explicit opt-in (`HERMES_WEBUI_GATEWAY_USE_RUNS_API=1` env var, or `webui_gateway_use_runs_api: true` in config), defaulting to off. Normal gateway chat now stays on `/v1/chat/completions`, which preserves the session via `X-Hermes-Session-Id`. Operators who want the in-gateway-chat approval-events flow can re-enable it with the flag. Thanks @rodboev. + ## [v0.51.476] — 2026-06-17 — Release QK (cross-provider model-pick + non-git update-status fixes) ### Fixed diff --git a/api/gateway_chat.py b/api/gateway_chat.py index ebc922b2..316173c3 100644 --- a/api/gateway_chat.py +++ b/api/gateway_chat.py @@ -36,6 +36,7 @@ _STREAM_RUN_IDS: dict[str, str] = {} _WEBUI_CHAT_BACKEND_ENV = "HERMES_WEBUI_CHAT_BACKEND" _WEBUI_GATEWAY_BASE_URL_ENV = "HERMES_WEBUI_GATEWAY_BASE_URL" _WEBUI_GATEWAY_API_KEY_ENV = "HERMES_WEBUI_GATEWAY_API_KEY" +_WEBUI_GATEWAY_USE_RUNS_API_ENV = "HERMES_WEBUI_GATEWAY_USE_RUNS_API" _GATEWAY_CHAT_BACKENDS = {"gateway", "api_server", "api-server"} @@ -83,6 +84,18 @@ def _gateway_api_key(environ: dict[str, str] | None = None) -> str: ).strip() +def _gateway_use_runs_api_enabled(config_data=None, environ: dict[str, str] | None = None) -> bool: + """Return True only when the operator has explicitly opted into the runs API path.""" + source = os.environ if environ is None else environ + cfg = config_data if isinstance(config_data, dict) else {} + raw = str( + source.get(_WEBUI_GATEWAY_USE_RUNS_API_ENV) + or cfg.get("webui_gateway_use_runs_api") + or "" + ).strip().lower() + return raw in ("1", "true", "yes", "on") + + def gateway_chat_config_status(config_data=None, environ: dict[str, str] | None = None) -> dict: """Return redacted Gateway-backed chat configuration status.""" mode = webui_chat_backend_mode(config_data, environ) @@ -545,7 +558,7 @@ def _run_gateway_chat_streaming( base_url = _gateway_base_url(cfg) api_key = _gateway_api_key() # Capability gate: use runs API when gateway advertises approval support. - _use_runs_api = gateway_supports_approval(base_url, api_key) + _use_runs_api = _gateway_use_runs_api_enabled(cfg) and gateway_supports_approval(base_url, api_key) if _use_runs_api: body_extras = {} if model_provider: diff --git a/tests/test_gateway_approval_runs_api.py b/tests/test_gateway_approval_runs_api.py index 8f080407..8f87960c 100644 --- a/tests/test_gateway_approval_runs_api.py +++ b/tests/test_gateway_approval_runs_api.py @@ -151,7 +151,7 @@ def test_gateway_runs_api_submission(): mock_session.pending_started_at = None try: - with patch.dict("os.environ", {"HERMES_WEBUI_CHAT_BACKEND": "gateway"}): + with patch.dict("os.environ", {"HERMES_WEBUI_CHAT_BACKEND": "gateway", "HERMES_WEBUI_GATEWAY_USE_RUNS_API": "1"}): with patch("api.gateway_chat.gateway_supports_approval", lambda *_args, **_kwargs: True), \ patch("api.gateway_chat._run_gateway_runs_api_streaming", fake_runs_streaming), \ patch("api.gateway_chat.get_session", return_value=mock_session), \ @@ -415,7 +415,7 @@ def test_gateway_runs_api_cancel_does_not_emit_empty_response(): return None, {} try: - with patch.dict("os.environ", {"HERMES_WEBUI_CHAT_BACKEND": "gateway"}): + with patch.dict("os.environ", {"HERMES_WEBUI_CHAT_BACKEND": "gateway", "HERMES_WEBUI_GATEWAY_USE_RUNS_API": "1"}): with patch("api.gateway_chat.gateway_supports_approval", return_value=True), \ patch("api.gateway_chat._run_gateway_runs_api_streaming", side_effect=fake_runs_streaming), \ patch("api.gateway_chat.get_session", return_value=mock_session): diff --git a/tests/test_webui_gateway_chat_backend.py b/tests/test_webui_gateway_chat_backend.py index afa33551..e48a3553 100644 --- a/tests/test_webui_gateway_chat_backend.py +++ b/tests/test_webui_gateway_chat_backend.py @@ -18,6 +18,7 @@ from api.gateway_chat import ( _gateway_sse_reasoning_delta, _gateway_stream_usage, _gateway_tool_progress_event, + _gateway_use_runs_api_enabled, gateway_chat_config_status, webui_chat_backend_mode, webui_gateway_chat_enabled, @@ -824,3 +825,84 @@ def test_gateway_chat_worker_forwards_image_attachments_as_multimodal_parts(tmp_ assert content[0] == {"type": "text", "text": "What is in this image?"} assert content[1]["type"] == "image_url" assert content[1]["image_url"]["url"].startswith("data:image/png;base64,") + + +def test_gateway_use_runs_api_is_default_off(): + for env in ({}, {"HERMES_WEBUI_GATEWAY_USE_RUNS_API": ""}): + assert _gateway_use_runs_api_enabled({}, env) is False + + +def test_gateway_use_runs_api_only_accepts_explicit_truthy_values(): + for value in ("1", "true", "yes", "on", " True ", " ON "): + assert _gateway_use_runs_api_enabled({}, {"HERMES_WEBUI_GATEWAY_USE_RUNS_API": value}) is True + + +def test_gateway_use_runs_api_rejects_generic_truthy_strings(): + for value in ("enabled", "gateway", "api_server", "absolutely"): + assert _gateway_use_runs_api_enabled({}, {"HERMES_WEBUI_GATEWAY_USE_RUNS_API": value}) is False + + +def test_gateway_use_runs_api_can_be_enabled_from_config(): + assert _gateway_use_runs_api_enabled({"webui_gateway_use_runs_api": "true"}, {}) is True + assert _gateway_use_runs_api_enabled({"webui_gateway_use_runs_api": "1"}, {}) is True + + +def test_gateway_use_runs_api_env_wins_over_config(): + assert _gateway_use_runs_api_enabled( + {"webui_gateway_use_runs_api": "true"}, + {"HERMES_WEBUI_GATEWAY_USE_RUNS_API": "false"}, + ) is False + + +def test_gateway_worker_skips_runs_api_when_opt_in_absent(): + """Worker uses chat/completions even when gateway advertises approval support, unless opt-in is set.""" + from unittest.mock import patch, MagicMock + from api.config import STREAMS, STREAMS_LOCK + from api.gateway_chat import _run_gateway_chat_streaming + + events = [] + q = MagicMock() + q.put_nowait = lambda item: events.append(item) + stream_id = "sid-optin-gate" + with STREAMS_LOCK: + STREAMS[stream_id] = q + + sse_body = b'data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n' + + def fake_urlopen(req, *, timeout=None): + assert "/v1/chat/completions" in req.full_url + resp = MagicMock() + resp.__iter__ = lambda s: iter(sse_body.split(b"\n")) + resp.__enter__ = lambda s: s + resp.__exit__ = lambda s, *a: None + return resp + + import os + env_override = {"HERMES_WEBUI_CHAT_BACKEND": "gateway"} + env_without_opt_in = { + k: v for k, v in os.environ.items() + if k != "HERMES_WEBUI_GATEWAY_USE_RUNS_API" + } + env_without_opt_in.update(env_override) + + try: + with patch.dict("os.environ", env_without_opt_in, clear=True): + with patch("api.gateway_chat.gateway_supports_approval", return_value=True), \ + patch("urllib.request.urlopen", side_effect=fake_urlopen), \ + patch("api.gateway_chat.get_session", return_value=MagicMock( + active_stream_id=stream_id, workspace="/tmp", + profile=None, context_messages=[], messages=[], + )): + _run_gateway_chat_streaming( + session_id="sess-optin", + msg_text="hi", + model="test", + workspace="/tmp", + stream_id=stream_id, + ) + event_types = [e[0] for e in events if isinstance(e, tuple) and len(e) >= 2] + assert "token" in event_types, "expected a token event from chat/completions path" + assert "apperror" not in event_types, "runs API path fired unexpectedly" + finally: + with STREAMS_LOCK: + STREAMS.pop(stream_id, None)