mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-19 22:20:30 +00:00
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:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user