feat(chat): load WebUI prefill context

This commit is contained in:
AJV20 committed 2026-05-18 14:04:49 -04:00
1 parent 8e65ad3063
commit 5c1161f84f
4 files changed
+203 -1

No files matched your search

+5
View File
@@ -2,6 +2,11 @@
## [Unreleased]
### Added
- Add non-sensitive SSE stream runtime diagnostics to deep health checks, including active stream count, subscriber totals, and offline buffered-event counts for stuck or slow WebUI chat investigations.
- Add WebUI session prefill parity: browser-originated chat turns now load configured prefill context from `prefill_messages_file` or `prefill_messages_script`, pass it to Hermes Agent as ephemeral model context, and surface a compact context status event in the chat UI without exposing prefill message bodies.
### Changed
- Add browser-surface session context to WebUI agent turns so the agent can distinguish a WebUI chat from messaging-platform transcripts while keeping the metadata ephemeral and out of saved history.
+120
View File
@@ -10,6 +10,8 @@ import mimetypes
import os
import queue
import re
import subprocess
import sys
import threading
import time
import traceback
@@ -226,6 +228,112 @@ def _webui_ephemeral_system_prompt(
return "\n\n".join(part for part in parts if part)
_SECRET_SHAPED_RE = re.compile(
r"(?i)(api[_-]?key|token|password|secret)\s*[:=]\s*[^\s]+|"
r"[A-Za-z0-9_-]{24,}\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}"
)
def _redact_prefill_status_text(text: str) -> str:
"""Return a short, non-secret diagnostic string for prefill status."""
clean = _SECRET_SHAPED_RE.sub("[REDACTED]", str(text or ""))
return " ".join(clean.split())[:240]
def _valid_prefill_messages(value) -> list[dict]:
"""Normalize a prefill payload to role/content messages."""
if not isinstance(value, list):
return []
messages: list[dict] = []
for item in value:
if not isinstance(item, dict):
continue
role = item.get("role")
content = item.get("content")
if role not in {"system", "user", "assistant"} or not isinstance(content, str) or not content.strip():
continue
messages.append({"role": role, "content": content})
return messages
def _resolve_prefill_path(raw: str) -> Path:
path = Path(str(raw)).expanduser()
if not path.is_absolute():
try:
from api.config import _get_config_path
path = _get_config_path().parent / path
except Exception:
path = Path.cwd() / path
return path
def _load_webui_prefill_context(
config_data: Optional[dict] = None,
*,
python_exe: Optional[str] = None,
env: Optional[dict] = None,
timeout: float = 20.0,
) -> dict:
"""Load configured WebUI session prefill messages.
Supports the same JSON-file shape used by Hermes Agent plus a WebUI-friendly
``prefill_messages_script`` hook whose stdout becomes one user-role recall
message. Script output is intentionally ephemeral: it is passed to the
agent as prefill context and is not written into the session transcript.
"""
cfg = config_data if isinstance(config_data, dict) else get_config()
file_raw = os.getenv("HERMES_PREFILL_MESSAGES_FILE", "") or str(cfg.get("prefill_messages_file") or "")
script_raw = os.getenv("HERMES_PREFILL_MESSAGES_SCRIPT", "") or str(cfg.get("prefill_messages_script") or "")
if file_raw:
path = _resolve_prefill_path(file_raw)
label = path.name or "prefill file"
if not path.exists():
return {"status": "error", "source": "file", "label": label, "messages": [], "message_count": 0, "error": "prefill file not found"}
try:
messages = _valid_prefill_messages(json.loads(path.read_text(encoding="utf-8")))
return {"status": "loaded", "source": "file", "label": label, "messages": messages, "message_count": len(messages)}
except Exception as exc:
return {"status": "error", "source": "file", "label": label, "messages": [], "message_count": 0, "error": _redact_prefill_status_text(str(exc))}
if script_raw:
path = _resolve_prefill_path(script_raw)
label = path.name or "prefill script"
if not path.exists():
return {"status": "error", "source": "script", "label": label, "messages": [], "message_count": 0, "error": "prefill script not found"}
exe = python_exe or sys.executable
cmd = [exe, str(path)] if path.suffix == ".py" else [str(path)]
try:
proc = subprocess.run(
cmd,
cwd=str(path.parent),
env=env,
text=True,
capture_output=True,
timeout=timeout,
check=False,
)
except Exception as exc:
return {"status": "error", "source": "script", "label": label, "messages": [], "message_count": 0, "error": _redact_prefill_status_text(str(exc))}
if proc.returncode != 0:
err = proc.stderr or proc.stdout or f"script exited with code {proc.returncode}"
return {"status": "error", "source": "script", "label": label, "messages": [], "message_count": 0, "error": _redact_prefill_status_text(err)}
output = (proc.stdout or "").strip()
messages = [{"role": "user", "content": output}] if output else []
status = "loaded" if messages else "empty"
return {"status": status, "source": "script", "label": label, "messages": messages, "message_count": len(messages)}
return {"status": "not_configured", "source": "none", "label": "", "messages": [], "message_count": 0}
def _public_prefill_context_status(prefill_context: dict) -> dict:
"""Strip message bodies before sending context status to the browser."""
return {
"status": prefill_context.get("status", "not_configured"),
"source": prefill_context.get("source", "none"),
"label": prefill_context.get("label", ""),
"message_count": int(prefill_context.get("message_count") or 0),
**({"error": prefill_context.get("error", "")} if prefill_context.get("error") else {}),
}
def _has_new_assistant_reply(all_messages: list, prev_count: int) -> bool:
"""Return True if *new* messages (beyond ``prev_count``) contain an
assistant message with non-empty content.
@@ -3544,6 +3652,12 @@ def _run_agent_streaming(
# Read per-profile config at call time (not module-level snapshot)
from api.config import get_config as _get_config
_cfg = _get_config()
_prefill_context = _load_webui_prefill_context(_cfg)
_prefill_messages = _prefill_context.get('messages') or []
put('context_status', {
'session_id': session_id,
'prefill': _public_prefill_context_status(_prefill_context),
})
# Per-profile toolsets — use _resolve_cli_toolsets() so MCP
# server toolsets are included, matching native CLI behaviour.
@@ -3666,6 +3780,7 @@ def _run_agent_streaming(
fallback_model=_fallback_resolved,
session_id=session_id,
session_db=_session_db,
prefill_messages=_prefill_messages,
stream_delta_callback=on_token,
reasoning_callback=on_reasoning,
tool_progress_callback=on_tool,
@@ -3679,6 +3794,8 @@ def _run_agent_streaming(
# but guard defensively to avoid TypeError on an older agent build.
if 'reasoning_config' in _agent_params and _reasoning_config is not None:
_agent_kwargs['reasoning_config'] = _reasoning_config
if 'prefill_messages' not in _agent_params:
_agent_kwargs.pop('prefill_messages', None)
if 'interim_assistant_callback' in _agent_params:
_agent_kwargs['interim_assistant_callback'] = on_interim_assistant
if 'tool_start_callback' in _agent_params:
@@ -3732,6 +3849,7 @@ def _run_agent_streaming(
_fallback_resolved or {},
sorted(_toolsets) if _toolsets else [],
_reasoning_config or {},
_public_prefill_context_status(_prefill_context),
# #1897: profile_home is part of the agent's identity because
# AIAgent caches `_cached_system_prompt` from `load_soul_md()`
# at construction time, sourced from HERMES_HOME. Same-session
@@ -3791,6 +3909,8 @@ def _run_agent_streaming(
agent.reasoning_callback = _agent_kwargs.get('reasoning_callback')
if hasattr(agent, 'clarify_callback'):
agent.clarify_callback = _agent_kwargs.get('clarify_callback')
if hasattr(agent, 'prefill_messages'):
agent.prefill_messages = list(_agent_kwargs.get('prefill_messages') or [])
if _session_db is not None:
# Close any previously held SessionDB connection before
# replacing it. Without this, each streaming request creates
+16 -1
View File
@@ -1520,6 +1520,21 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}catch(_){}
});
source.addEventListener('context_status',e=>{
let d={};
try{ d=JSON.parse(e.data||'{}'); }catch(_){}
if((d.session_id||activeSid)!==activeSid) return;
const prefill=d.prefill||{};
const status=String(prefill.status||'not_configured');
const label=String(prefill.label||'session recall');
if(status==='loaded'){
setComposerStatus(`Context loaded: ${label}`);
}else if(status==='error'){
setComposerStatus(`Context unavailable: ${label}`);
if(typeof showToast==='function') showToast(`Context unavailable: ${String(prefill.error||label)}`,3600,'warning');
}
});
function _resolveGoalMessage(d){
const key=String(d && d.message_key ? d.message_key : '').trim();
const args=Array.isArray(d && d.message_args) ? d.message_args : [];
@@ -1996,7 +2011,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_setActivePaneIdleIfOwner();
});
for(const _runJournalEventName of ['token','interim_assistant','reasoning','tool','tool_complete','approval','clarify','title','title_status','goal','goal_continue','done','stream_end','pending_steer_leftover','compressing','compressed','metering','apperror','warning','error','cancel']){
for(const _runJournalEventName of ['token','interim_assistant','reasoning','tool','tool_complete','approval','clarify','title','title_status','context_status','goal','goal_continue','done','stream_end','pending_steer_leftover','compressing','compressed','metering','apperror','warning','error','cancel']){
source.addEventListener(_runJournalEventName,_rememberRunJournalCursor);
}
}
+62
View File
@@ -0,0 +1,62 @@
"""Regression tests for WebUI session prefill parity."""
from __future__ import annotations
import json
import os
import stat
import sys
from pathlib import Path
def test_prefill_script_output_becomes_safe_user_message(tmp_path):
from api.streaming import _load_webui_prefill_context
script = tmp_path / "recall.py"
script.write_text("print('JOPLIN SESSION RECALL\\nCurrent Context: loaded')\n", encoding="utf-8")
script.chmod(script.stat().st_mode | stat.S_IXUSR)
result = _load_webui_prefill_context(
{"prefill_messages_script": str(script)},
python_exe=sys.executable,
env={"PATH": os.environ.get("PATH", "")},
)
assert result["status"] == "loaded"
assert result["source"] == "script"
assert result["label"] == "recall.py"
assert result["message_count"] == 1
assert result["messages"] == [
{
"role": "user",
"content": "JOPLIN SESSION RECALL\nCurrent Context: loaded",
}
]
def test_prefill_json_file_is_loaded_without_script_execution(tmp_path):
from api.streaming import _load_webui_prefill_context
prefill = tmp_path / "prefill.json"
prefill.write_text(json.dumps([{"role": "user", "content": "Pinned context"}]), encoding="utf-8")
result = _load_webui_prefill_context({"prefill_messages_file": str(prefill)}, python_exe=sys.executable)
assert result["status"] == "loaded"
assert result["source"] == "file"
assert result["label"] == "prefill.json"
assert result["messages"] == [{"role": "user", "content": "Pinned context"}]
def test_prefill_context_redacts_secret_shaped_errors(tmp_path):
from api.streaming import _load_webui_prefill_context
missing = tmp_path / "missing.py"
result = _load_webui_prefill_context(
{"prefill_messages_script": str(missing)},
python_exe=sys.executable,
)
assert result["status"] == "error"
assert result["messages"] == []
assert "missing.py" in result["label"]
assert "token" not in result.get("error", "").lower()