fix(streaming): inject WebUI delivery context via system prompt, not prefill user message (#3324, @aether-agent)

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 <aether-agent@users.noreply.github.com>
This commit is contained in:
nesquena-hermes
2026-06-02 19:36:25 +00:00
parent 60369cab69
commit 1630fb8d95
3 changed files with 107 additions and 39 deletions
+5
View File
@@ -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
+45 -38
View File
@@ -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;
+57 -1
View File
@@ -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