Merge pull request #3144 from nesquena/release/stage-batch41

Release v0.51.159 — Release EE (stage-batch41: 5-PR low-risk cleanup)
This commit is contained in:
nesquena-hermes
2026-05-29 15:28:50 -07:00
committed by GitHub
13 changed files with 349 additions and 7 deletions
+13
View File
@@ -3,6 +3,19 @@
## [Unreleased]
## [v0.51.159] — 2026-05-29 — Release EE (stage-batch41 — 5-PR low-risk cleanup: Gateway tool-progress forwarding + shutdown diagnostics + CLI snippet-limit parity + numpad-Enter submit + sync-chat notes guardrail)
### Changed
- WebUI's durable-notes guardrail now also applies to sync chat and explicitly asks agents to leave external notes and durable memory unchanged unless a turn contains an explicit capture or reusable durable signal; durable note writes should be summarized back to the user.
### Fixed
- Gateway-backed browser chat now forwards Hermes Gateway `hermes.tool.progress` SSE events into WebUI's live tool/activity stream, so Gateway runs no longer appear idle while server-side tools are running.
- WebUI now logs structured shutdown diagnostics when the server exits or `/api/shutdown` is called, including active stream IDs to help diagnose interrupted turns after restarts.
- The chat composer now treats the numeric keypad Enter key as a submit shortcut even when the send-key preference is set to Ctrl/Cmd+Enter, while preserving regular Enter-as-newline behavior in that mode.
- The CLI tool-result snippet limit in the browser now matches the backend (`_TOOL_RESULT_SNIPPET_MAX = 4000`), so longer non-diff CLI tool output is no longer truncated to 200 characters before reaching the tool card.
## [v0.51.158] — 2026-05-29 — Release ED (stage-batch40 — 5-PR low-risk cleanup: numpad/keyboard composer fixes + Joplin search auth + provider-qualified model preservation + SSE fallback poll throttle + assistant-reply polish)
### Changed
+56 -1
View File
@@ -142,6 +142,28 @@ def _gateway_stream_usage(payload: dict) -> dict:
}
def _gateway_tool_progress_event(payload: dict) -> tuple[str, dict] | None:
"""Translate Hermes Gateway tool-progress SSE payloads to WebUI events."""
if not isinstance(payload, dict):
return None
name = str(payload.get("tool") or payload.get("name") or payload.get("function_name") or "").strip()
if not name or name.startswith("_"):
return None
status = str(payload.get("status") or "running").strip().lower()
tid = payload.get("toolCallId") or payload.get("tool_call_id") or payload.get("id")
is_complete = status in {"completed", "complete", "success", "error", "failed"}
event_payload = {
"event_type": "tool.completed" if is_complete else "tool.started",
"name": name,
"preview": payload.get("label") or payload.get("preview"),
"args": payload.get("args") if isinstance(payload.get("args"), dict) else {},
"is_error": status in {"error", "failed"},
}
if tid:
event_payload["tid"] = str(tid)
return ("tool_complete" if is_complete else "tool"), event_payload
def _stream_writeback_is_current(session: Any, stream_id: str) -> bool:
return bool(stream_id and getattr(session, "active_stream_id", None) == stream_id)
@@ -276,13 +298,20 @@ def _run_gateway_chat_streaming(
)
update_active_run(stream_id, phase="gateway-request")
last_payload = {}
sse_event = "message"
with urllib.request.urlopen(req, timeout=600) as resp:
for raw_line in resp:
if cancel_event.is_set():
put_gateway_event("cancel", {"message": "Cancelled by user"})
return
line = raw_line.decode("utf-8", errors="replace").strip()
if not line or not line.startswith("data:"):
if not line:
sse_event = "message"
continue
if line.startswith("event:"):
sse_event = line[6:].strip() or "message"
continue
if not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
@@ -291,6 +320,32 @@ def _run_gateway_chat_streaming(
payload = json.loads(data)
except json.JSONDecodeError:
continue
if sse_event == "hermes.tool.progress":
translated = _gateway_tool_progress_event(payload)
if translated:
event_name, event_payload = translated
if stream_id in STREAM_LIVE_TOOL_CALLS:
if event_name == "tool":
STREAM_LIVE_TOOL_CALLS[stream_id].append({
"name": event_payload.get("name"),
"args": event_payload.get("args") or {},
"done": False,
**({"tid": event_payload.get("tid")} if event_payload.get("tid") else {}),
})
else:
for shared_tc in reversed(STREAM_LIVE_TOOL_CALLS[stream_id]):
if shared_tc.get("done"):
continue
if (
event_payload.get("tid") and shared_tc.get("tid") == event_payload.get("tid")
) or shared_tc.get("name") == event_payload.get("name"):
shared_tc["done"] = True
shared_tc["is_error"] = bool(event_payload.get("is_error"))
break
put_gateway_event(event_name, event_payload)
update_active_run(stream_id, phase="gateway-tool", latest_tool=event_payload.get("name"))
sse_event = "message"
continue
last_payload = payload
delta = _gateway_sse_delta(payload)
if delta:
+38 -1
View File
@@ -3852,8 +3852,39 @@ def _serve_shell_unavailable(handler, exc: Exception) -> bool:
return True
_SHUTDOWN_LOG_VALUE_RE = re.compile(r"[\x00-\x1f\x7f]+")
def _shutdown_log_value(value, *, default: str = "unknown", max_len: int = 160) -> str:
"""Return a bounded single-line value safe for shutdown diagnostics."""
if value is None:
return default
try:
text = str(value)
except Exception:
return default
text = _SHUTDOWN_LOG_VALUE_RE.sub("?", text).strip()
if not text:
return default
if len(text) > max_len:
text = f"{text[:max_len]}"
return text
def _handle_shutdown(handler) -> bool:
"""Shut down the WebUI server process."""
headers = getattr(handler, "headers", {})
ua = headers.get("User-Agent", "no-ua") if hasattr(headers, "get") else "no-ua"
remote = "unknown"
if getattr(handler, "client_address", None):
remote = getattr(handler, "client_address", ("unknown",))[0]
logger.info(
"[shutdown-request] remote=%s method=%s path=%s ua=%s",
_shutdown_log_value(remote),
_shutdown_log_value(getattr(handler, "command", None)),
_shutdown_log_value(getattr(handler, "path", None), max_len=240),
_shutdown_log_value(ua, default="no-ua", max_len=240),
)
j(handler, {"status": "shutting_down"})
import signal
import threading
@@ -9648,7 +9679,13 @@ def _handle_chat_sync(handler, body):
"[Workspace::v1: ...] tag as your default working directory for ALL file operations: "
"write_file, read_file, search_files, terminal workdir, and patch. "
"Never fall back to a hardcoded path when this tag is present.\n\n"
f"{_WEBUI_PROGRESS_PROMPT}"
f"{_WEBUI_PROGRESS_PROMPT}\n\n"
"WebUI external-notes/durable-memory policy: Do not copy or dump this browser transcript "
"into external notes or durable memory by default. Write or update durable "
"notes only for explicit captures, durable preferences, decisions, blockers/open "
"issues, runbook-worthy workflows, or other clearly reusable signals; otherwise "
"leave external notes and durable memory unchanged. When you do write or update a durable note, briefly tell "
"the user what note or section changed so the write is reviewable."
)
_previous_messages = list(s.messages or [])
+3 -1
View File
@@ -223,7 +223,9 @@ def _webui_surface_context_prompt(surface_context: Optional[dict]) -> str:
"WebUI session context:",
"- This browser session is not the same live transcript as Telegram, Discord, Slack, or other messaging surfaces.",
"- Use durable memory, saved sessions, and available tools for cross-surface recall instead of assuming those transcripts are in this browser chat.",
"- Do not copy or dump this browser transcript into external notes or durable memory by default. Save only explicit captures, durable user preferences, decisions, blockers, runbook-worthy workflows, or other clearly reusable signals.",
"- Do not copy or dump this browser transcript into external notes or durable memory by default.",
"- Write to external notes or durable memory only for explicit captures, durable user preferences, decisions, blockers/open issues, runbook-worthy workflows, or other clearly reusable signals; otherwise leave notes unchanged.",
"- When you do write or update a durable note, briefly tell the user what note/section changed so the write is reviewable.",
]
fields = (
("source", "Source"),
+59
View File
@@ -8,6 +8,7 @@ import os
import re
import socket
import sys
import threading
import time
import traceback
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -403,6 +404,63 @@ def _raise_fd_soft_limit(target: int = 4096) -> dict:
return {"status": "raised", "soft": desired, "hard": hard, "previous_soft": soft}
_SHUTDOWN_AUDIT_LOGGED = False
_SHUTDOWN_LOG_VALUE_RE = re.compile(r"[\x00-\x1f\x7f]+")
def _shutdown_log_value(value, *, default: str = "unknown", max_len: int = 160) -> str:
"""Return a bounded single-line value safe for shutdown diagnostics."""
if value is None:
return default
try:
text = str(value)
except Exception:
return default
text = _SHUTDOWN_LOG_VALUE_RE.sub("?", text).strip()
if not text:
return default
if len(text) > max_len:
text = f"{text[:max_len]}"
return text
def _log_shutdown_audit(reason: str = "serve_forever_exit") -> None:
"""Log runtime context when the WebUI server is exiting."""
global _SHUTDOWN_AUDIT_LOGGED
if _SHUTDOWN_AUDIT_LOGGED:
return
active_sessions = []
try:
from api.models import LOCK, SESSIONS
with LOCK:
session_items = list(SESSIONS.items())
for sid, session in session_items:
stream_id = getattr(session, "active_stream_id", None)
if stream_id:
pending = bool(getattr(session, "pending_user_message", None))
active_sessions.append(
"sid=%s stream=%s pending=%s"
% (
_shutdown_log_value(sid),
_shutdown_log_value(stream_id),
pending,
)
)
except Exception:
logger.debug("Failed to collect active-session shutdown audit state", exc_info=True)
_SHUTDOWN_AUDIT_LOGGED = True
logger.info(
"[shutdown-audit] reason=%s pid=%s thread=%s(%s) active_sessions=[%s]",
_shutdown_log_value(reason),
os.getpid(),
_shutdown_log_value(threading.current_thread().name),
threading.current_thread().ident,
"; ".join(active_sessions) if active_sessions else "none",
)
def main() -> None:
from api.config import print_startup_config, verify_hermes_imports, _HERMES_FOUND
@@ -516,6 +574,7 @@ def main() -> None:
try:
httpd.serve_forever()
finally:
_log_shutdown_audit()
# Stop the gateway watcher on shutdown
try:
from api.gateway_watcher import stop_watcher
+5 -1
View File
@@ -1127,6 +1127,9 @@ function _isVirtualKeyboardLikelyOpen(){
function _hasFinePointerCoexisting(){
try{ return matchMedia('(any-pointer:fine)').matches; }catch(_){ return false; }
}
function _isNumpadEnter(e){
return e.key==='Enter'&&(e.code==='NumpadEnter'||e.location===KeyboardEvent.DOM_KEY_LOCATION_NUMPAD);
}
$('msg').addEventListener('keydown',e=>{
// Autocomplete navigation when dropdown is open
const dd=$('cmdDropdown');
@@ -1151,12 +1154,13 @@ $('msg').addEventListener('keydown',e=>{
// Users can override in Settings by explicitly choosing 'enter' mode.
if(e.key==='Enter'){
if(_isImeEnter(e)){return;}
const isNumpadEnter=_isNumpadEnter(e);
const _mobileDefault=matchMedia('(pointer:coarse)').matches
&&!_hasFinePointerCoexisting()
&&window._sendKey==='enter'
&&_isVirtualKeyboardLikelyOpen();
if(window._sendKey==='ctrl+enter'||_mobileDefault){
if(e.ctrlKey||e.metaKey){e.preventDefault();send();}
if(isNumpadEnter||e.ctrlKey||e.metaKey){e.preventDefault();send();}
} else {
if(!e.shiftKey){e.preventDefault();send();}
}
+1 -1
View File
@@ -6118,7 +6118,7 @@ function _cliLooksLikePatchDiff(text){
function _cliToolResultSnippet(raw){
const fullText=_cliToolResultText(raw);
if(_cliLooksLikePatchDiff(fullText)) return _clipCliToolSnippet(fullText);
return String(fullText||'').slice(0,200);
return String(fullText||'').slice(0,4000);
}
function _prefixedCliDiffLines(prefix, value){
@@ -21,7 +21,7 @@ def test_cli_tool_result_diff_snippet_is_not_cut_to_200_chars():
"if(_cliLooksLikePatchDiff(fullText))return_clipCliToolSnippet(fullText);"
in COMPACT_UI
)
assert "returnString(fullText||'').slice(0,200);" in COMPACT_UI
assert "returnString(fullText||'').slice(0,4000);" in COMPACT_UI
def test_cli_tool_fallback_promotes_apply_patch_args_to_tool_card_snippet():
+26
View File
@@ -0,0 +1,26 @@
"""Keyboard contract for treating Numpad Enter as a submit shortcut."""
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
def test_boot_defines_numpad_enter_helper():
assert "function _isNumpadEnter(e)" in BOOT_JS
assert "e.code==='NumpadEnter'" in BOOT_JS
assert "e.location===KeyboardEvent.DOM_KEY_LOCATION_NUMPAD" in BOOT_JS
def test_ctrl_enter_mode_allows_numpad_enter_to_submit():
ctrl_branch = BOOT_JS.split("if(window._sendKey==='ctrl+enter'||_mobileDefault){", 1)[1]
ctrl_branch = ctrl_branch.split("} else {", 1)[0]
assert "isNumpadEnter" in ctrl_branch
assert "if(isNumpadEnter||e.ctrlKey||e.metaKey){e.preventDefault();send();}" in ctrl_branch
def test_ime_guard_runs_before_numpad_enter_detection():
enter_branch = BOOT_JS.split("if(e.key==='Enter'){")[-1]
ime_idx = enter_branch.index("if(_isImeEnter(e)){return;}")
numpad_idx = enter_branch.index("const isNumpadEnter=_isNumpadEnter(e);")
assert ime_idx < numpad_idx
+71
View File
@@ -0,0 +1,71 @@
import logging
import types
import threading
def test_server_shutdown_audit_logs_active_stream_context(monkeypatch, caplog):
import server
from api import models
monkeypatch.setattr(server, "_SHUTDOWN_AUDIT_LOGGED", False)
monkeypatch.setitem(
models.SESSIONS,
"session-1\nforged",
types.SimpleNamespace(active_stream_id="stream-1\rforged", pending_user_message="hello"),
)
monkeypatch.setitem(
models.SESSIONS,
"session-2",
types.SimpleNamespace(active_stream_id=None, pending_user_message=None),
)
caplog.set_level(logging.INFO, logger="server")
server._log_shutdown_audit(reason="test-exit")
logged = "\n".join(record.getMessage() for record in caplog.records)
assert "[shutdown-audit]" in logged
assert "reason=test-exit" in logged
assert "sid=session-1?forged stream=stream-1?forged pending=True" in logged
assert "session-1\nforged" not in logged
assert "stream-1\rforged" not in logged
assert "session-2" not in logged
def test_shutdown_route_logs_request_context_without_starting_real_shutdown(monkeypatch, caplog):
from api import routes
responses = []
monkeypatch.setattr(routes, "j", lambda handler, payload, **kw: responses.append(payload) or True)
started_threads = []
class FakeThread:
def __init__(self, target, daemon=False):
self.target = target
self.daemon = daemon
def start(self):
started_threads.append((self.target, self.daemon))
monkeypatch.setattr(threading, "Thread", FakeThread)
handler = types.SimpleNamespace(
client_address=("127.0.0.1", 12345),
command="POST",
path="/api/shutdown\nforged",
headers={"User-Agent": "pytest-agent\r\nforged"},
)
caplog.set_level(logging.INFO, logger="api.routes")
assert routes._handle_shutdown(handler) is True
logged = "\n".join(record.getMessage() for record in caplog.records)
assert "[shutdown-request]" in logged
assert "remote=127.0.0.1" in logged
assert "method=POST" in logged
assert "path=/api/shutdown?forged" in logged
assert "ua=pytest-agent?forged" in logged
assert "/api/shutdown\nforged" not in logged
assert "pytest-agent\r\nforged" not in logged
assert responses == [{"status": "shutting_down"}]
assert started_threads and started_threads[0][1] is True
+11
View File
@@ -0,0 +1,11 @@
"""Regression test: JS tool-result snippet limit matches the Python backend limit."""
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
def test_tool_snippet_limit_parity():
py = (REPO / "api" / "streaming.py").read_text(encoding="utf-8")
js = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
assert "_TOOL_RESULT_SNIPPET_MAX = 4000" in py
assert ".slice(0,4000)" in js
+62 -1
View File
@@ -15,6 +15,7 @@ from api.gateway_chat import (
_gateway_http_error_event,
_gateway_sse_delta,
_gateway_stream_usage,
_gateway_tool_progress_event,
gateway_chat_config_status,
webui_chat_backend_mode,
webui_gateway_chat_enabled,
@@ -97,6 +98,41 @@ def test_gateway_stream_usage_normalizes_token_names():
assert _gateway_stream_usage({}) == {}
def test_gateway_tool_progress_event_translates_gateway_lifecycle_payloads():
assert _gateway_tool_progress_event(
{
"tool": "terminal",
"label": "terminal: pytest",
"toolCallId": "call-1",
"status": "running",
}
) == (
"tool",
{
"event_type": "tool.started",
"name": "terminal",
"preview": "terminal: pytest",
"args": {},
"is_error": False,
"tid": "call-1",
},
)
assert _gateway_tool_progress_event(
{"tool": "terminal", "toolCallId": "call-1", "status": "completed"}
) == (
"tool_complete",
{
"event_type": "tool.completed",
"name": "terminal",
"preview": None,
"args": {},
"is_error": False,
"tid": "call-1",
},
)
assert _gateway_tool_progress_event({"tool": "_thinking", "status": "running"}) is None
def test_gateway_http_401_reports_gateway_auth_not_provider_key():
exc = urllib.error.HTTPError(
"http://gateway.local/v1/chat/completions",
@@ -190,7 +226,11 @@ def test_gateway_chat_worker_translates_sse_and_persists_session(tmp_path, monke
return False
def __iter__(self):
yield b'event: hermes.tool.progress\n'
yield b'data: {"tool":"terminal","label":"terminal: pytest","toolCallId":"call-1","status":"running"}\n\n'
yield b'data: {"choices":[{"delta":{"content":"hel"}}]}\n\n'
yield b'event: hermes.tool.progress\n'
yield b'data: {"tool":"terminal","toolCallId":"call-1","status":"completed"}\n\n'
yield b'data: {"choices":[{"delta":{"content":"lo"}}],"usage":{"prompt_tokens":4,"completion_tokens":2}}\n\n'
yield b'data: [DONE]\n\n'
@@ -213,7 +253,9 @@ def test_gateway_chat_worker_translates_sse_and_persists_session(tmp_path, monke
s.pending_attachments = []
s.pending_started_at = 123
s.save()
STREAMS[stream_id] = create_stream_channel()
channel = create_stream_channel()
subscriber = channel.subscribe()
STREAMS[stream_id] = channel
gateway_chat._run_gateway_chat_streaming(
s.session_id,
@@ -243,6 +285,25 @@ def test_gateway_chat_worker_translates_sse_and_persists_session(tmp_path, monke
"webui session context",
"Say hello",
]
events = []
while not subscriber.empty():
events.append(subscriber.get_nowait())
assert ("tool", {
"event_type": "tool.started",
"name": "terminal",
"preview": "terminal: pytest",
"args": {},
"is_error": False,
"tid": "call-1",
}) in events
assert ("tool_complete", {
"event_type": "tool.completed",
"name": "terminal",
"preview": None,
"args": {},
"is_error": False,
"tid": "call-1",
}) in events
def test_gateway_chat_worker_forwards_image_attachments_as_multimodal_parts(tmp_path, monkeypatch):
+3
View File
@@ -20,6 +20,9 @@ def test_webui_ephemeral_prompt_includes_browser_surface_context():
assert "Workspace: /tmp/example-workspace" in prompt
assert "not the same live transcript as Telegram" in prompt
assert "Do not copy or dump this browser transcript" in prompt
assert "Write to external notes or durable memory only" in prompt
assert "otherwise leave notes unchanged" in prompt
assert "what note/section changed" in prompt
assert "explicit captures" in prompt
assert "durable user preferences" in prompt
assert "Do not include terse planning fragments" in prompt