feat(chat): load WebUI prefill context
This commit is contained in:
1 parent
8e65ad3063
commit
5c1161f84f
4 files changed
+203
-1
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user