feat(chat): add WebUI prefill script hook
This commit is contained in:
1 parent
e50d6ccc8c
commit
fa57868431
5 files changed
+196
-15
No files matched your search
@@ -122,6 +122,9 @@ Environment variables controlling behavior:
|
|||||||
HERMES_WEBUI_DEFAULT_MODEL Optional model override; unset means provider default
|
HERMES_WEBUI_DEFAULT_MODEL Optional model override; unset means provider default
|
||||||
HERMES_WEBUI_PASSWORD Optional: enable password auth (off by default)
|
HERMES_WEBUI_PASSWORD Optional: enable password auth (off by default)
|
||||||
HERMES_WEBUI_SKIP_ONBOARDING Optional: bypass the first-run onboarding wizard
|
HERMES_WEBUI_SKIP_ONBOARDING Optional: bypass the first-run onboarding wizard
|
||||||
|
HERMES_PREFILL_MESSAGES_FILE Optional JSON message list for browser-turn prefill context
|
||||||
|
HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT Optional command that prints JSON messages or text prefill context
|
||||||
|
HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT_TIMEOUT Optional script timeout in seconds (default 5, max 30)
|
||||||
HERMES_HOME Base directory for Hermes state (~/.hermes by default)
|
HERMES_HOME Base directory for Hermes state (~/.hermes by default)
|
||||||
|
|
||||||
Test isolation environment variables (set by conftest.py):
|
Test isolation environment variables (set by conftest.py):
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- WebUI can now opt into a `webui_prefill_messages_script` / `HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT` hook for dynamic browser-turn prefill context from local notes or recall systems. The script output is normalized to ephemeral prefill messages and browser status still hides message bodies while redacting script errors.
|
||||||
|
|
||||||
## [v0.51.131] — 2026-05-24 — Release DC (stage-batch13 — 6-PR notes-drawer + context-parity + PWA-swipe + locale polish)
|
## [v0.51.131] — 2026-05-24 — Release DC (stage-batch13 — 6-PR notes-drawer + context-parity + PWA-swipe + locale polish)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -121,6 +121,38 @@ For self-hosted VM or homelab installs, `ctl.sh` wraps the common daemon lifecyc
|
|||||||
|
|
||||||
`ctl.sh start` runs the bootstrap in foreground/no-browser mode behind the daemon wrapper, writes logs to `~/.hermes/webui.log`, and respects `.env` plus inline overrides such as `HERMES_WEBUI_HOST=0.0.0.0 ./ctl.sh start`.
|
`ctl.sh start` runs the bootstrap in foreground/no-browser mode behind the daemon wrapper, writes logs to `~/.hermes/webui.log`, and respects `.env` plus inline overrides such as `HERMES_WEBUI_HOST=0.0.0.0 ./ctl.sh start`.
|
||||||
|
|
||||||
|
### Optional session recall prefill
|
||||||
|
|
||||||
|
WebUI can attach ephemeral prefill messages to new browser-originated
|
||||||
|
agent turns. This is useful when a deployment already has a local recall script
|
||||||
|
for Joplin, Obsidian, Notion, llm-wiki, or another third-party notes source and
|
||||||
|
wants the browser chat to receive the same high-level context as other Hermes
|
||||||
|
surfaces.
|
||||||
|
|
||||||
|
Static JSON remains supported through `prefill_messages_file` or
|
||||||
|
`HERMES_PREFILL_MESSAGES_FILE`. For dynamic recall, opt in explicitly with a
|
||||||
|
WebUI-specific script hook:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
webui_prefill_messages_script:
|
||||||
|
- python3
|
||||||
|
- /path/to/notes_recall.py
|
||||||
|
webui_prefill_messages_script_timeout: 5
|
||||||
|
```
|
||||||
|
|
||||||
|
or:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT="python3 /path/to/notes_recall.py" \
|
||||||
|
HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT_TIMEOUT=5 \
|
||||||
|
./ctl.sh restart
|
||||||
|
```
|
||||||
|
|
||||||
|
The script may print either an OpenAI-style JSON message list, a JSON object with
|
||||||
|
a `messages` list, or plain text; plain text is wrapped as one `system` prefill
|
||||||
|
message. The browser only receives a compact status event (`source`, `label`,
|
||||||
|
message count, and redacted errors), never the prefill message bodies.
|
||||||
|
|
||||||
The bootstrap will:
|
The bootstrap will:
|
||||||
|
|
||||||
1. Detect Hermes Agent and, if missing, attempt the official installer (`curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash`).
|
1. Detect Hermes Agent and, if missing, attempt the official installer (`curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash`).
|
||||||
|
|||||||
+92
-14
@@ -10,7 +10,9 @@ import mimetypes
|
|||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
import re
|
import re
|
||||||
|
import shlex
|
||||||
import sys
|
import sys
|
||||||
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
@@ -285,29 +287,105 @@ def _resolve_prefill_path(raw: str) -> Path:
|
|||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _prefill_not_configured() -> dict:
|
||||||
|
return {"status": "not_configured", "source": "none", "label": "", "messages": [], "message_count": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_prefill_messages_file(file_raw: str, *, source: str = "file", status: str = "loaded") -> dict:
|
||||||
|
path = _resolve_prefill_path(file_raw)
|
||||||
|
label = path.name or "prefill file"
|
||||||
|
if not path.exists():
|
||||||
|
return {"status": "error", "source": source, "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": status, "source": source, "label": label, "messages": messages, "message_count": len(messages)}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"status": "error", "source": source, "label": label, "messages": [], "message_count": 0, "error": _redact_prefill_status_text(str(exc))}
|
||||||
|
|
||||||
|
|
||||||
|
def _prefill_script_timeout(config_data: dict) -> float:
|
||||||
|
raw = os.getenv("HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT_TIMEOUT", "") or str(config_data.get("webui_prefill_messages_script_timeout") or "")
|
||||||
|
try:
|
||||||
|
return max(0.1, min(float(raw or 5), 30.0))
|
||||||
|
except Exception:
|
||||||
|
return 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def _prefill_script_command(raw) -> list[str]:
|
||||||
|
if isinstance(raw, (list, tuple)):
|
||||||
|
return [str(part) for part in raw if str(part)]
|
||||||
|
parts = shlex.split(str(raw or ""))
|
||||||
|
if not parts:
|
||||||
|
return []
|
||||||
|
# A single script path mirrors prefill_messages_file path resolution. More
|
||||||
|
# complex commands keep their argv untouched so admins can pass arguments.
|
||||||
|
if len(parts) == 1:
|
||||||
|
parts[0] = str(_resolve_prefill_path(parts[0]))
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def _messages_from_prefill_script_output(text: str) -> list[dict]:
|
||||||
|
stripped = str(text or "").strip()
|
||||||
|
if not stripped:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
payload = json.loads(stripped)
|
||||||
|
except Exception:
|
||||||
|
payload = None
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
payload = payload.get("messages")
|
||||||
|
messages = _valid_prefill_messages(payload)
|
||||||
|
if messages:
|
||||||
|
return messages
|
||||||
|
return [{"role": "system", "content": stripped}]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_prefill_messages_script(config_data: dict) -> dict:
|
||||||
|
script_raw = os.getenv("HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT", "") or config_data.get("webui_prefill_messages_script")
|
||||||
|
if not script_raw:
|
||||||
|
return _prefill_not_configured()
|
||||||
|
command = _prefill_script_command(script_raw)
|
||||||
|
label = Path(command[0]).name if command else "prefill script"
|
||||||
|
if not command:
|
||||||
|
return {"status": "error", "source": "script", "label": label, "messages": [], "message_count": 0, "error": "prefill script is empty"}
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
command,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
timeout=_prefill_script_timeout(config_data),
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return {"status": "error", "source": "script", "label": label, "messages": [], "message_count": 0, "error": "prefill script timed out"}
|
||||||
|
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 = _redact_prefill_status_text(proc.stderr or proc.stdout or f"prefill script exited {proc.returncode}")
|
||||||
|
return {"status": "error", "source": "script", "label": label, "messages": [], "message_count": 0, "error": err}
|
||||||
|
messages = _messages_from_prefill_script_output(proc.stdout)
|
||||||
|
return {"status": "loaded", "source": "script", "label": label, "messages": messages, "message_count": len(messages)}
|
||||||
|
|
||||||
|
|
||||||
def _load_webui_prefill_context(
|
def _load_webui_prefill_context(
|
||||||
config_data: Optional[dict] = None,
|
config_data: Optional[dict] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Load configured WebUI session prefill messages.
|
"""Load configured WebUI session prefill messages.
|
||||||
|
|
||||||
Supports the same bounded JSON-file shape used by Hermes Agent. WebUI does
|
Supports the same bounded JSON-file shape used by Hermes Agent. WebUI also
|
||||||
not execute a configured prefill script here; session recall that requires
|
supports its own explicitly opt-in script hook so admins can bridge Joplin,
|
||||||
code execution should go through the normal MCP/tool path instead of an
|
Obsidian, Notion, llm-wiki, or another local notes source into ephemeral
|
||||||
always-on per-turn subprocess before SSE starts.
|
turn context without baking any one note provider into the WebUI.
|
||||||
"""
|
"""
|
||||||
cfg = config_data if isinstance(config_data, dict) else get_config()
|
cfg = config_data if isinstance(config_data, dict) else get_config()
|
||||||
|
script_context = _load_prefill_messages_script(cfg)
|
||||||
|
if script_context.get("status") != "not_configured":
|
||||||
|
return script_context
|
||||||
file_raw = os.getenv("HERMES_PREFILL_MESSAGES_FILE", "") or str(cfg.get("prefill_messages_file") or "")
|
file_raw = os.getenv("HERMES_PREFILL_MESSAGES_FILE", "") or str(cfg.get("prefill_messages_file") or "")
|
||||||
if file_raw:
|
if file_raw:
|
||||||
path = _resolve_prefill_path(file_raw)
|
return _load_prefill_messages_file(file_raw)
|
||||||
label = path.name or "prefill file"
|
return _prefill_not_configured()
|
||||||
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))}
|
|
||||||
return {"status": "not_configured", "source": "none", "label": "", "messages": [], "message_count": 0}
|
|
||||||
|
|
||||||
|
|
||||||
def _public_prefill_context_status(prefill_context: dict) -> dict:
|
def _public_prefill_context_status(prefill_context: dict) -> dict:
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
def test_prefill_json_file_keeps_valid_roles_and_drops_invalid_items(tmp_path):
|
def test_prefill_json_file_keeps_valid_roles_and_drops_invalid_items(tmp_path):
|
||||||
@@ -32,7 +34,7 @@ def test_prefill_json_file_keeps_valid_roles_and_drops_invalid_items(tmp_path):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_prefill_script_config_is_ignored_in_webui(tmp_path):
|
def test_prefill_script_config_is_not_used_without_webui_opt_in(tmp_path):
|
||||||
from api.streaming import _load_webui_prefill_context
|
from api.streaming import _load_webui_prefill_context
|
||||||
|
|
||||||
script = tmp_path / "recall.py"
|
script = tmp_path / "recall.py"
|
||||||
@@ -49,6 +51,68 @@ def test_prefill_script_config_is_ignored_in_webui(tmp_path):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_webui_prefill_script_loads_json_messages(tmp_path):
|
||||||
|
from api.streaming import _load_webui_prefill_context
|
||||||
|
|
||||||
|
script = tmp_path / "recall.py"
|
||||||
|
script.write_text(
|
||||||
|
"import json\n"
|
||||||
|
"print(json.dumps([{'role': 'system', 'content': 'Joplin recall'}, {'role': 'tool', 'content': 'drop me'}]))\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _load_webui_prefill_context({"webui_prefill_messages_script": [sys.executable, str(script)]})
|
||||||
|
|
||||||
|
assert result["status"] == "loaded"
|
||||||
|
assert result["source"] == "script"
|
||||||
|
assert result["label"] == Path(sys.executable).name
|
||||||
|
assert result["messages"] == [{"role": "system", "content": "Joplin recall"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_webui_prefill_script_wraps_plain_text_for_any_notes_source(tmp_path):
|
||||||
|
from api.streaming import _load_webui_prefill_context
|
||||||
|
|
||||||
|
script = tmp_path / "obsidian_recall.py"
|
||||||
|
script.write_text("print('Obsidian project note context')\n", encoding="utf-8")
|
||||||
|
|
||||||
|
result = _load_webui_prefill_context({"webui_prefill_messages_script": [sys.executable, str(script)]})
|
||||||
|
|
||||||
|
assert result["status"] == "loaded"
|
||||||
|
assert result["source"] == "script"
|
||||||
|
assert result["messages"] == [{"role": "system", "content": "Obsidian project note context"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_webui_prefill_script_errors_are_redacted(tmp_path):
|
||||||
|
from api.streaming import _load_webui_prefill_context
|
||||||
|
|
||||||
|
script = tmp_path / "bad_recall.py"
|
||||||
|
script.write_text("import sys; print('token=redaction-test-placeholder', file=sys.stderr); raise SystemExit(2)\n", encoding="utf-8")
|
||||||
|
|
||||||
|
result = _load_webui_prefill_context({"webui_prefill_messages_script": [sys.executable, str(script)]})
|
||||||
|
|
||||||
|
assert result["status"] == "error"
|
||||||
|
assert result["source"] == "script"
|
||||||
|
assert "redaction-test-placeholder" not in result["error"]
|
||||||
|
assert "[REDACTED]" in result["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_webui_prefill_script_takes_precedence_over_static_file(tmp_path):
|
||||||
|
from api.streaming import _load_webui_prefill_context
|
||||||
|
|
||||||
|
prefill = tmp_path / "prefill.json"
|
||||||
|
prefill.write_text(json.dumps([{"role": "system", "content": "static"}]), encoding="utf-8")
|
||||||
|
script = tmp_path / "recall.py"
|
||||||
|
script.write_text("print('dynamic')\n", encoding="utf-8")
|
||||||
|
|
||||||
|
result = _load_webui_prefill_context({
|
||||||
|
"prefill_messages_file": str(prefill),
|
||||||
|
"webui_prefill_messages_script": [sys.executable, str(script)],
|
||||||
|
})
|
||||||
|
|
||||||
|
assert result["source"] == "script"
|
||||||
|
assert result["messages"] == [{"role": "system", "content": "dynamic"}]
|
||||||
|
|
||||||
|
|
||||||
def test_public_prefill_status_strips_message_bodies():
|
def test_public_prefill_status_strips_message_bodies():
|
||||||
from api.streaming import _public_prefill_context_status
|
from api.streaming import _public_prefill_context_status
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user