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] 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 aeb9911bc..092cde30a 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 d1178df0b..612daf814 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 373f7861a..aea35c4c0 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