From 1630fb8d9531f7e5e566aeea543282c00a97821e Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Tue, 2 Jun 2026 19:36:25 +0000 Subject: [PATCH 1/3] fix(streaming): inject WebUI delivery context via system prompt, not prefill user message (#3324, @aether-agent) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consecutive user turns (session-context prefill + actual message) made models with strict chat templates (Mistral, Gemma via llama.cpp) reject the request with a Jinja 500. Move the platform/delivery context (connected platforms, home channels, scheduled-task delivery hints) from _webui_session_context_message (a prefill user message) into _webui_delivery_context_prompt, appended to the ephemeral system prompt. Session framing stays in _webui_surface_context_prompt. Context is preserved — just role-alternation-safe. Closes #3276. Co-authored-by: aether-agent --- CHANGELOG.md | 5 ++ api/streaming.py | 83 ++++++++++++++++------------- tests/test_webui_surface_context.py | 58 +++++++++++++++++++- 3 files changed, 107 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aeb9911b..092cde30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ ## [Unreleased] +## [v0.51.216] — 2026-06-02 — Release GJ (stage-p2e — fix consecutive-user-turn rejection on strict chat templates) + +### Fixed +- WebUI session/delivery context (connected platforms, home channels, scheduled-task delivery hints) is now injected into the ephemeral **system prompt** instead of being appended as a prefill `user` message. The old prefill produced two consecutive `user` turns (session context + the actual message), which models with strict chat templates (Mistral, Gemma via llama.cpp) reject with a Jinja 500. The same context is preserved — just delivered in a role-alternation-safe place (#3324, @aether-agent, closes #3276). + ## [v0.51.215] — 2026-06-02 — Release GI (stage-p2d — deduplicate legacy messages in append-only merge) ### Fixed diff --git a/api/streaming.py b/api/streaming.py index d1178df0..612daf81 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -245,6 +245,7 @@ def _webui_surface_context_prompt(surface_context: Optional[dict]) -> str: def _webui_ephemeral_system_prompt( personality_prompt: Optional[str], surface_context: Optional[dict] = None, + config_data: Optional[dict] = None, ) -> str: """Build WebUI-only runtime instructions that are not persisted to history.""" parts = [] @@ -254,6 +255,9 @@ def _webui_ephemeral_system_prompt( if surface_prompt: parts.append(surface_prompt) parts.append(_WEBUI_PROGRESS_PROMPT) + delivery_prompt = _webui_delivery_context_prompt(config_data) + if delivery_prompt: + parts.append(delivery_prompt) return "\n\n".join(part for part in parts if part) @@ -498,48 +502,45 @@ def _public_prefill_context_status(prefill_context: dict) -> dict: } -def _webui_session_context_message(config_data: Optional[dict] = None) -> dict: - """Return a compact browser-session context message for WebUI agents. +def _webui_delivery_context_prompt(config_data: Optional[dict] = None) -> str: + """Return platform/delivery context for the ephemeral system prompt. - Messaging gateway sessions get a small "Current Session Context" block that - tells the agent where the turn came from, which platforms are connected, and - how scheduled-task delivery should be interpreted. Browser-originated WebUI - turns do not have a Gateway ``SessionSource``, but they still benefit from a - safe equivalent so the model understands that this is a WebUI session, not a - literal Telegram thread, while retaining access to configured messaging - delivery targets. + Connected platforms, home channels, and scheduled-task delivery hints + are injected into the system prompt (safe for role alternation) rather + than as a prefill ``user`` message, which strict chat templates (Mistral, + Gemma) reject. + + NOTE: This function only covers platform/delivery info. The session + framing (\"Source: WebUI\", \"Session ID\", \"Profile\", \"Workspace\") is + emitted by ``_webui_surface_context_prompt()``, which is called from + ``_webui_ephemeral_system_prompt()`` before this helper. If you + refactor this area, keep that surface call in place — the two helpers + together produce the full session context block. """ cfg = config_data if isinstance(config_data, dict) else get_config() - lines = [ - "## Current Session Context", - "", - "**Source:** WebUI (browser session)", - "**Session type:** Browser-originated Hermes WebUI chat. This is a separate WebUI transcript, not the same live Telegram/Discord/other messaging thread.", - ] + lines: list[str] = [] + display_hermes_home = None try: - from api.profiles import get_active_profile_name - - profile_name = get_active_profile_name() or "default" + from hermes_constants import get_hermes_home, display_hermes_home as _dh + display_hermes_home = _dh except Exception: - profile_name = "default" - lines.append(f"**Active Hermes profile:** {profile_name}") + get_hermes_home = None # type: ignore[assignment] connected = ["local (files on this machine)"] try: - from hermes_constants import get_hermes_home, display_hermes_home - - state_path = get_hermes_home() / "gateway_state.json" - if state_path.exists(): - raw_state = json.loads(state_path.read_text(encoding="utf-8")) - platforms = raw_state.get("platforms") if isinstance(raw_state, dict) else {} - if isinstance(platforms, dict): - for name in sorted(platforms): - pdata = platforms.get(name) or {} - if isinstance(pdata, dict) and pdata.get("state") == "connected" and name != "local": - connected.append(f"{name}: Connected ✓") + if get_hermes_home is not None: + state_path = get_hermes_home() / "gateway_state.json" + if state_path.exists(): + raw_state = json.loads(state_path.read_text(encoding="utf-8")) + platforms = raw_state.get("platforms") if isinstance(raw_state, dict) else {} + if isinstance(platforms, dict): + for name in sorted(platforms): + pdata = platforms.get(name) or {} + if isinstance(pdata, dict) and pdata.get("state") == "connected" and name != "local": + connected.append(f"{name}: Connected ✓") except Exception: - display_hermes_home = None # type: ignore[assignment] + pass lines.append(f"**Connected Platforms:** {', '.join(connected)}") home_channels = {} @@ -567,7 +568,7 @@ def _webui_session_context_message(config_data: Optional[dict] = None) -> dict: lines.append("**Delivery options for scheduled tasks:**") lines.append("- `\"origin\"` → Back to this WebUI/browser session when the WebUI runtime supports origin delivery; otherwise prefer an explicit platform target.") try: - home_display = display_hermes_home() if display_hermes_home else "~/.hermes" # type: ignore[name-defined] + home_display = display_hermes_home() if display_hermes_home else "~/.hermes" except Exception: home_display = "~/.hermes" lines.append(f"- `\"local\"` → Save to local files only ({home_display}/cron/output/)") @@ -576,14 +577,19 @@ def _webui_session_context_message(config_data: Optional[dict] = None) -> dict: lines.append("") lines.append("*For explicit targeting, use `\"platform:chat_id\"` format if the user provides a specific chat ID. Do not invent private IDs.*") - return {"role": "user", "content": "\n".join(lines)} + return "\n".join(lines) def _prefill_messages_with_webui_context(prefill_context: dict, config_data: Optional[dict] = None) -> list[dict]: - """Combine recall prefill with WebUI's gateway-like session context.""" - messages = list(prefill_context.get("messages") or []) - messages.append(_webui_session_context_message(config_data)) - return messages + """Combine recall prefill with WebUI session context. + + The session context (connected platforms, delivery hints) is injected + via ``_webui_ephemeral_system_prompt`` / ``ephemeral_system_prompt`` + instead of as a prefill ``user`` message. Adding it as a user message + creates two consecutive user turns (prefill + actual) which strict chat + templates (Mistral, Gemma) reject with a Jinja 500. + """ + return list(prefill_context.get("messages") or []) def _has_new_assistant_reply(all_messages: list, prev_count: int) -> bool: @@ -5198,6 +5204,7 @@ def _run_agent_streaming( 'profile': getattr(s, 'profile', None), 'workspace': s.workspace, }, + config_data=_cfg, ) _pending_started_at = getattr(s, 'pending_started_at', None) # Normal chat-start sets pending_started_at before spawning this thread; diff --git a/tests/test_webui_surface_context.py b/tests/test_webui_surface_context.py index 373f7861..aea35c4c 100644 --- a/tests/test_webui_surface_context.py +++ b/tests/test_webui_surface_context.py @@ -1,4 +1,4 @@ -from api.streaming import _webui_ephemeral_system_prompt +from api.streaming import _webui_ephemeral_system_prompt, _prefill_messages_with_webui_context def test_webui_ephemeral_prompt_includes_browser_surface_context(): @@ -50,3 +50,59 @@ def test_webui_ephemeral_prompt_skips_empty_surface_fields(): assert "Session ID:" not in prompt assert "Profile:" not in prompt assert "Workspace:" not in prompt + + +def test_ephemeral_prompt_avoids_platform_info_when_no_config(): + """Without config_data, the delivery context falls back to defaults.""" + prompt = _webui_ephemeral_system_prompt( + "Be concise.", + surface_context={"source": "webui"}, + ) + + # Core platform headings should still appear (fallback data). + assert "Connected Platforms:" in prompt + assert "Delivery options for scheduled tasks:" in prompt + # But home channels are only present when the config has them. + assert "Home Channels" not in prompt + + +def test_prefill_no_longer_adds_session_context_user_message(): + """_prefill_messages_with_webui_context must NOT append a user message. + + Strict chat templates (Mistral, Gemma) require user/assistant alternation. + Adding a 'user' session context message creates two consecutive user turns. + """ + prefill = {"messages": [{"role": "system", "content": "recall note"}]} + result = _prefill_messages_with_webui_context(prefill) + assert len(result) == 1 + assert result[0]["role"] == "system" + assert "Connected Platforms" not in result[0].get("content", "") + + +def test_prefill_preserves_empty_and_none_messages(): + """Edge cases: empty prefill stays empty, missing key returns empty.""" + assert _prefill_messages_with_webui_context({"messages": []}) == [] + assert _prefill_messages_with_webui_context({}) == [] + assert _prefill_messages_with_webui_context({"messages": None}) == [] + + +def test_delivery_context_includes_home_channels_when_configured(): + """When config_data has platforms with a home_channel, the prompt includes it.""" + config = { + "platforms": { + "telegram": { + "enabled": True, + "home_channel": {"name": "General"}, + }, + }, + } + prompt = _webui_ephemeral_system_prompt( + None, + surface_context={"source": "webui"}, + config_data=config, + ) + + assert "Connected Platforms:" in prompt + assert "Home Channels (default destinations):" in prompt + assert "telegram: General" in prompt + assert "telegram" in prompt and "Home channel" in prompt From 8ad0db5dbeb59b57f36da7d5142b4013d4709f59 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Tue, 2 Jun 2026 19:42:14 +0000 Subject: [PATCH 2/3] fix(gateway): carry WebUI delivery context on gateway-backed path too (Codex MUST-FIX on #3324) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3324 moved the WebUI session/delivery context from a prefill user message into _webui_ephemeral_system_prompt, but the gateway-backed WebUI chat path built its own system message from the bare _WEBUI_PROGRESS_PROMPT + prefill helper — which now returns only recall prefill. That silently dropped connected platforms, home channels, delivery hints, and session framing on Gateway-routed WebUI chats (same #3278 data-drop class, on the path the author missed). Build the gateway request's system message with _webui_ephemeral_system_prompt(surface_context, config_data=cfg) so the context is preserved. Regression test asserts the delivery context is present in the gateway system message. --- api/gateway_chat.py | 19 +++++++++++++++++-- tests/test_webui_gateway_chat_backend.py | 18 +++++++++++++----- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/api/gateway_chat.py b/api/gateway_chat.py index 13d4d7fc..89f850e2 100644 --- a/api/gateway_chat.py +++ b/api/gateway_chat.py @@ -247,15 +247,30 @@ def _run_gateway_chat_streaming( cfg = get_config() try: from api.streaming import ( - _WEBUI_PROGRESS_PROMPT, _load_webui_prefill_context, _prefill_messages_with_webui_context, _public_prefill_context_status, + _webui_ephemeral_system_prompt, ) prefill_context = _load_webui_prefill_context(cfg) + # #3324: the WebUI session/delivery context (connected platforms, + # home channels, delivery hints, session framing) is now carried in + # the ephemeral system prompt rather than a prefill `user` message. + # The gateway-backed path must build the SAME system prompt so that + # context is not silently dropped on Gateway-routed WebUI chats. + _gateway_system_prompt = _webui_ephemeral_system_prompt( + None, + surface_context={ + "source": "webui", + "session_id": session_id, + "profile": getattr(s, "profile", None), + "workspace": s.workspace if s is not None else str(workspace), + }, + config_data=cfg, + ) prefill_messages = [ - {"role": "system", "content": _WEBUI_PROGRESS_PROMPT}, + {"role": "system", "content": _gateway_system_prompt}, *_prefill_messages_with_webui_context(prefill_context, cfg), ] put_gateway_event("context_status", { diff --git a/tests/test_webui_gateway_chat_backend.py b/tests/test_webui_gateway_chat_backend.py index 9a87bbc1..e26053a6 100644 --- a/tests/test_webui_gateway_chat_backend.py +++ b/tests/test_webui_gateway_chat_backend.py @@ -285,15 +285,23 @@ def test_gateway_chat_worker_translates_sse_and_persists_session(tmp_path, monke assert captured["headers"]["X-hermes-session-key"] == f"webui:{s.session_id}" assert '"stream": true' in captured["body"] payload = json.loads(captured["body"]) - assert [m["content"] for m in payload["messages"]] == [ - streaming._WEBUI_PROGRESS_PROMPT, + # #3324: the gateway path's first system message is now the full WebUI + # ephemeral system prompt (progress prompt + session/delivery context), + # NOT the bare _WEBUI_PROGRESS_PROMPT — otherwise the delivery/session + # context is silently dropped on Gateway-routed WebUI chats. + system_msg = payload["messages"][0] + assert system_msg["role"] == "system" + assert "Final visible assistant replies" in system_msg["content"] + assert "Need script" in system_msg["content"] + # The moved session/delivery context must be present in the system prompt. + assert "Connected Platforms:" in system_msg["content"] + assert "Delivery options for scheduled tasks:" in system_msg["content"] + # The recall prefill + the actual user turn follow the system message. + assert [m["content"] for m in payload["messages"][1:]] == [ "prefill", "webui session context", "Say hello", ] - assert payload["messages"][0]["role"] == "system" - assert "Final visible assistant replies" in payload["messages"][0]["content"] - assert "Need script" in payload["messages"][0]["content"] events = [] while not subscriber.empty(): events.append(subscriber.get_nowait()) From f47b5b45aa272b319d0294fa42fe95f5b999c738 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Tue, 2 Jun 2026 19:43:26 +0000 Subject: [PATCH 3/3] test: rewrite gateway-like-metadata test for #3324 system-prompt relocation (Opus MUST-FIX) test_webui_session_context_adds_gateway_like_metadata asserted the removed prefill-user-message API. Rewrote it to assert (a) the prefill helper no longer appends a session-context user message, and (b) the same gateway-like metadata (connected platforms, paused-platform exclusion, home-channel name shown, chat_id never leaks) is now carried in _webui_ephemeral_system_prompt. --- tests/test_webui_prefill_context.py | 56 +++++++++++++++++++---------- 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/tests/test_webui_prefill_context.py b/tests/test_webui_prefill_context.py index 79a01304..9d18f8f9 100644 --- a/tests/test_webui_prefill_context.py +++ b/tests/test_webui_prefill_context.py @@ -264,7 +264,17 @@ def test_public_prefill_status_strips_message_bodies(): def test_webui_session_context_adds_gateway_like_metadata(monkeypatch, tmp_path): - from api.streaming import _prefill_messages_with_webui_context + # #3324: the gateway-like session/delivery context moved OUT of a prefill + # `user` message (which broke strict chat templates with two consecutive + # user turns) and INTO the ephemeral system prompt. _prefill_messages_with_webui_context + # now returns only recall prefill; the metadata is asserted on + # _webui_ephemeral_system_prompt instead. The invariants preserved here: + # paused platforms excluded, connected platforms shown, home-channel name + # shown, and the chat_id never leaks. + from api.streaming import ( + _prefill_messages_with_webui_context, + _webui_ephemeral_system_prompt, + ) gateway_state = tmp_path / "gateway_state.json" gateway_state.write_text( @@ -290,27 +300,37 @@ def test_webui_session_context_adds_gateway_like_metadata(monkeypatch, tmp_path) ) monkeypatch.setitem(sys.modules, "hermes_constants", fake_constants) + config_data = { + "platforms": { + "telegram": { + "enabled": True, + "home_channel": {"name": "Home DM", "chat_id": "should-not-leak"}, + } + } + } + + # The prefill helper no longer appends a session-context user message. messages = _prefill_messages_with_webui_context( {"messages": [{"role": "user", "content": "recall"}]}, - { - "platforms": { - "telegram": { - "enabled": True, - "home_channel": {"name": "Home DM", "chat_id": "should-not-leak"}, - } - } - }, + config_data, ) + assert messages == [{"role": "user", "content": "recall"}] + assert not any( + isinstance(m, dict) and "## Current Session Context" in str(m.get("content", "")) + for m in messages + ), "session context must NOT be appended as a prefill user message anymore (#3324)" - assert messages[0] == {"role": "user", "content": "recall"} - context = messages[-1] - assert context["role"] == "user" - assert "## Current Session Context" in context["content"] - assert "**Source:** WebUI (browser session)" in context["content"] - assert "telegram: Connected" in context["content"] - assert "discord: Connected" not in context["content"] - assert "Home DM" in context["content"] - assert "should-not-leak" not in context["content"] + # The same gateway-like metadata is now carried in the ephemeral system prompt. + prompt = _webui_ephemeral_system_prompt( + None, + surface_context={"source": "webui"}, + config_data=config_data, + ) + assert "**Connected Platforms:**" in prompt + assert "telegram: Connected" in prompt + assert "discord: Connected" not in prompt # paused platform excluded + assert "Home DM" in prompt + assert "should-not-leak" not in prompt # chat_id must never leak def test_prefill_status_redactor_handles_secret_shaped_text():