From ebb4dffc7d6e3069f7efa225bba42b63a344e458 Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Tue, 19 May 2026 14:50:26 -0400 Subject: [PATCH 01/14] fix: stream live tool callback events --- CHANGELOG.md | 5 ++ api/streaming.py | 71 ++++++++++++++++++++++--- static/boot.js | 17 ++++++ tests/test_command_asset_fallbacks.py | 30 +++++++++++ tests/test_live_tool_callback_events.py | 63 ++++++++++++++++++++++ 5 files changed, 179 insertions(+), 7 deletions(-) create mode 100644 tests/test_command_asset_fallbacks.py create mode 100644 tests/test_live_tool_callback_events.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cfb09c64..f83d2346 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +### Fixed + +- Surface live tool activity when Hermes Agent reports tools through its dedicated `tool_start_callback` / `tool_complete_callback` path, so browser chat shows the existing running tool cards instead of appearing idle until the final answer. +- Keep the composer usable if a transient restart/proxy blip prevents `commands.js` from loading while `boot.js` still attaches slash-command listeners. + ## [v0.51.93] — 2026-05-19 — Release BQ (stage-386 — 10-PR full sweep batch — RFC Slice 4 runner/sidecar gate + workspace tree toggle width CSS variable + settled file:// markdown link rendering + prompt-cache coverage percentage fix + terminal shell shutdown reap + configured model picker provider preservation + profile-aware assistant display names + state.db reconciliation slice 1 + queued-message cross-session drain fix + stale-stream writeback supersede) diff --git a/api/streaming.py b/api/streaming.py index 9df92b7c..76b92b9e 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -3450,10 +3450,20 @@ def _run_agent_streaming( # closes over it) never captures an unbound name even if this # block is reordered later (Issue #765). _checkpoint_activity = [0] + _live_tool_event_start_ids = set() + _live_tool_event_complete_ids = set() + + def _tool_args_snapshot(args): + args_snap = {} + if isinstance(args, dict): + for k, v in list(args.items())[:4]: + s2 = str(v) + args_snap[k] = s2[:120] + ('...' if len(s2) > 120 else '') + return args_snap def _record_live_tool_start(tool_call_id, name, args): if not tool_call_id or tool_call_id in _live_prompt_estimate_seen_ids: - return + return False _live_prompt_estimate_seen_ids.add(tool_call_id) _tool_call = { 'id': tool_call_id, @@ -3468,10 +3478,11 @@ def _run_agent_streaming( 'content': '', 'tool_calls': [_tool_call], }]) + return True def _record_live_tool_complete(tool_call_id, name, function_result): if not tool_call_id: - return + return False _result_text = _tool_result_snippet(function_result) _bump_live_prompt_estimate([{ 'role': 'tool', @@ -3479,6 +3490,7 @@ def _run_agent_streaming( 'tool_call_id': tool_call_id, 'content': _result_text, }]) + return True def on_tool(*cb_args, **cb_kwargs): nonlocal _reasoning_text @@ -3511,11 +3523,7 @@ def _run_agent_streaming( _emit_metering() return - args_snap = {} - if isinstance(args, dict): - for k, v in list(args.items())[:4]: - s2 = str(v) - args_snap[k] = s2[:120] + ('...' if len(s2) > 120 else '') + args_snap = _tool_args_snapshot(args) if event_type in (None, 'tool.started'): _live_tool_calls.append({ @@ -3591,6 +3599,28 @@ def _run_agent_streaming( def on_tool_start(tool_call_id, name, args): try: _record_live_tool_start(tool_call_id, name, args) + if tool_call_id and tool_call_id not in _live_tool_event_start_ids: + _live_tool_event_start_ids.add(tool_call_id) + _live_tool_calls.append({ + 'name': name, + 'args': args if isinstance(args, dict) else {}, + 'tid': tool_call_id, + }) + # Mirror to shared dict so cancel_stream() can persist it (#1361 §B) + if stream_id in STREAM_LIVE_TOOL_CALLS: + STREAM_LIVE_TOOL_CALLS[stream_id].append({ + 'name': name, + 'args': args if isinstance(args, dict) else {}, + 'done': False, + 'tid': tool_call_id, + }) + put('tool', { + 'event_type': 'tool.started', + 'name': name, + 'preview': None, + 'args': _tool_args_snapshot(args), + 'tid': tool_call_id, + }) _tool_stats = meter().get_stats() _tool_stats['session_id'] = session_id _tool_stats['usage'] = _live_usage_snapshot() @@ -3601,6 +3631,33 @@ def _run_agent_streaming( def on_tool_complete(tool_call_id, name, args, function_result): try: _record_live_tool_complete(tool_call_id, name, function_result) + if tool_call_id and tool_call_id not in _live_tool_event_complete_ids: + _live_tool_event_complete_ids.add(tool_call_id) + result_snippet = _tool_result_snippet(function_result) + for live_tc in reversed(_live_tool_calls): + if live_tc.get('done'): + continue + if live_tc.get('tid') == tool_call_id or (not live_tc.get('tid') and live_tc.get('name') == name): + live_tc['done'] = True + live_tc['snippet'] = result_snippet + break + if stream_id in STREAM_LIVE_TOOL_CALLS: + for shared_tc in reversed(STREAM_LIVE_TOOL_CALLS[stream_id]): + if shared_tc.get('done'): + continue + if shared_tc.get('tid') == tool_call_id or (not shared_tc.get('tid') and shared_tc.get('name') == name): + shared_tc['done'] = True + shared_tc['snippet'] = result_snippet + break + _checkpoint_activity[0] += 1 + put('tool_complete', { + 'event_type': 'tool.completed', + 'name': name, + 'preview': result_snippet, + 'args': _tool_args_snapshot(args), + 'tid': tool_call_id, + 'is_error': False, + }) _tool_stats = meter().get_stats() _tool_stats['session_id'] = session_id _tool_stats['usage'] = _live_usage_snapshot() diff --git a/static/boot.js b/static/boot.js index 9a30fe8e..eda383a4 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1,3 +1,20 @@ +// Slash-command helpers normally come from commands.js, which is loaded before +// boot.js. If a restart/proxy blip makes that asset fail while boot.js loads, +// keep the composer usable instead of throwing ReferenceError on input/keydown. +(function(){ + function dropdown(){ return document.getElementById('cmdDropdown'); } + if(typeof window.hideCmdDropdown!=='function'){ + window.hideCmdDropdown=function(){ + const dd=dropdown(); + if(dd){ dd.classList.remove('open'); dd.style.display='none'; } + }; + } + if(typeof window.showCmdDropdown!=='function') window.showCmdDropdown=function(){}; + if(typeof window.getMatchingCommands!=='function') window.getMatchingCommands=function(){ return []; }; + if(typeof window.navigateCmdDropdown!=='function') window.navigateCmdDropdown=function(){}; + if(typeof window.selectCmdDropdownItem!=='function') window.selectCmdDropdownItem=function(){}; +})(); + async function cancelStream(){ const streamId = S.activeStreamId; if(!streamId) return; diff --git a/tests/test_command_asset_fallbacks.py b/tests/test_command_asset_fallbacks.py new file mode 100644 index 00000000..2d1bcefc --- /dev/null +++ b/tests/test_command_asset_fallbacks.py @@ -0,0 +1,30 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def _read(relpath: str) -> str: + return (ROOT / relpath).read_text(encoding="utf-8") + + +def test_boot_installs_safe_slash_command_fallbacks_when_commands_asset_missing(): + boot = _read("static/boot.js") + + assert "If a restart/proxy blip makes that asset fail" in boot + for name in ( + "hideCmdDropdown", + "showCmdDropdown", + "getMatchingCommands", + "navigateCmdDropdown", + "selectCmdDropdownItem", + ): + assert f"typeof window.{name}!==\'function\'" in boot or f"typeof window.{name}!='function'" in boot + + +def test_commands_asset_still_owns_real_dropdown_implementation(): + commands = _read("static/commands.js") + + assert "function hideCmdDropdown()" in commands + assert "function showCmdDropdown(" in commands + assert "function getMatchingCommands(" in commands diff --git a/tests/test_live_tool_callback_events.py b/tests/test_live_tool_callback_events.py new file mode 100644 index 00000000..0e5dd3ee --- /dev/null +++ b/tests/test_live_tool_callback_events.py @@ -0,0 +1,63 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def _read(relpath: str) -> str: + return (ROOT / relpath).read_text(encoding="utf-8") + + +def _function_block(src: str, name: str) -> str: + start = src.find(f"def {name}") + assert start != -1, f"{name} not found" + next_def = src.find("\n def ", start + 1) + assert next_def != -1, f"end of {name} not found" + return src[start:next_def] + + +def test_tool_start_callback_emits_existing_tool_sse_event_with_tool_id(): + src = _read("api/streaming.py") + block = _function_block(src, "on_tool_start") + + assert "put('tool'" in block, ( + "The dedicated Hermes Agent tool_start_callback must emit the existing " + "tool SSE event; otherwise WebUI stays visually silent while tools run." + ) + assert "'event_type': 'tool.started'" in block + assert "'tid': tool_call_id" in block, ( + "Live frontend cards need the tool_call_id so tool_complete can update " + "the running card in place." + ) + assert "_live_tool_event_start_ids" in block, ( + "Tool start SSE emission should be idempotent per callback id." + ) + assert "STREAM_LIVE_TOOL_CALLS" in block and "'done': False" in block + + +def test_tool_complete_callback_emits_existing_tool_complete_sse_event_with_tool_id(): + src = _read("api/streaming.py") + block = _function_block(src, "on_tool_complete") + + assert "put('tool_complete'" in block, ( + "The dedicated Hermes Agent tool_complete_callback must emit the existing " + "tool_complete SSE event so the frontend can settle the running tool card." + ) + assert "'event_type': 'tool.completed'" in block + assert "'tid': tool_call_id" in block + assert "_live_tool_event_complete_ids" in block, ( + "Tool completion SSE emission should be idempotent per callback id." + ) + assert "result_snippet = _tool_result_snippet(function_result)" in block + assert "_checkpoint_activity[0] += 1" in block + + +def test_tool_callback_events_keep_existing_frontend_event_contract(): + messages = _read("static/messages.js") + ui = _read("static/ui.js") + + assert "source.addEventListener('tool',e=>{" in messages + assert "source.addEventListener('tool_complete',e=>{" in messages + assert "tid:d.tid" in messages + assert "data-live-tid" in ui + assert "existing.replaceWith(replacement)" in ui From 612fcd30fe1ca218f14cd28321900c6090bc057b Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Tue, 19 May 2026 18:41:08 -0400 Subject: [PATCH 02/14] fix: avoid duplicate live tool events --- CHANGELOG.md | 1 - api/streaming.py | 10 +++++++++ static/boot.js | 17 -------------- tests/test_command_asset_fallbacks.py | 30 ------------------------- tests/test_live_tool_callback_events.py | 10 +++++++++ 5 files changed, 20 insertions(+), 48 deletions(-) delete mode 100644 tests/test_command_asset_fallbacks.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f83d2346..c9328df5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,6 @@ ### Fixed - Surface live tool activity when Hermes Agent reports tools through its dedicated `tool_start_callback` / `tool_complete_callback` path, so browser chat shows the existing running tool cards instead of appearing idle until the final answer. -- Keep the composer usable if a transient restart/proxy blip prevents `commands.js` from loading while `boot.js` still attaches slash-command listeners. ## [v0.51.93] — 2026-05-19 — Release BQ (stage-386 — 10-PR full sweep batch — RFC Slice 4 runner/sidecar gate + workspace tree toggle width CSS variable + settled file:// markdown link rendering + prompt-cache coverage percentage fix + terminal shell shutdown reap + configured model picker provider preservation + profile-aware assistant display names + state.db reconciliation slice 1 + queued-message cross-session drain fix + stale-stream writeback supersede) diff --git a/api/streaming.py b/api/streaming.py index 76b92b9e..e7ca4a82 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -3525,6 +3525,13 @@ def _run_agent_streaming( args_snap = _tool_args_snapshot(args) + # Modern Hermes Agent builds can call both tool_progress_callback + # and the structured tool_start/tool_complete callbacks for the + # same tool. Prefer the structured path when it is supported so + # the browser receives one tid-tagged tool card per real call. + if event_type in (None, 'tool.started') and 'tool_start_callback' in _agent_params: + return + if event_type in (None, 'tool.started'): _live_tool_calls.append({ 'name': name, @@ -3560,6 +3567,9 @@ def _run_agent_streaming( pass return + if event_type == 'tool.completed' and 'tool_complete_callback' in _agent_params: + return + if event_type == 'tool.completed': for live_tc in reversed(_live_tool_calls): if live_tc.get('done'): diff --git a/static/boot.js b/static/boot.js index eda383a4..9a30fe8e 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1,20 +1,3 @@ -// Slash-command helpers normally come from commands.js, which is loaded before -// boot.js. If a restart/proxy blip makes that asset fail while boot.js loads, -// keep the composer usable instead of throwing ReferenceError on input/keydown. -(function(){ - function dropdown(){ return document.getElementById('cmdDropdown'); } - if(typeof window.hideCmdDropdown!=='function'){ - window.hideCmdDropdown=function(){ - const dd=dropdown(); - if(dd){ dd.classList.remove('open'); dd.style.display='none'; } - }; - } - if(typeof window.showCmdDropdown!=='function') window.showCmdDropdown=function(){}; - if(typeof window.getMatchingCommands!=='function') window.getMatchingCommands=function(){ return []; }; - if(typeof window.navigateCmdDropdown!=='function') window.navigateCmdDropdown=function(){}; - if(typeof window.selectCmdDropdownItem!=='function') window.selectCmdDropdownItem=function(){}; -})(); - async function cancelStream(){ const streamId = S.activeStreamId; if(!streamId) return; diff --git a/tests/test_command_asset_fallbacks.py b/tests/test_command_asset_fallbacks.py deleted file mode 100644 index 2d1bcefc..00000000 --- a/tests/test_command_asset_fallbacks.py +++ /dev/null @@ -1,30 +0,0 @@ -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] - - -def _read(relpath: str) -> str: - return (ROOT / relpath).read_text(encoding="utf-8") - - -def test_boot_installs_safe_slash_command_fallbacks_when_commands_asset_missing(): - boot = _read("static/boot.js") - - assert "If a restart/proxy blip makes that asset fail" in boot - for name in ( - "hideCmdDropdown", - "showCmdDropdown", - "getMatchingCommands", - "navigateCmdDropdown", - "selectCmdDropdownItem", - ): - assert f"typeof window.{name}!==\'function\'" in boot or f"typeof window.{name}!='function'" in boot - - -def test_commands_asset_still_owns_real_dropdown_implementation(): - commands = _read("static/commands.js") - - assert "function hideCmdDropdown()" in commands - assert "function showCmdDropdown(" in commands - assert "function getMatchingCommands(" in commands diff --git a/tests/test_live_tool_callback_events.py b/tests/test_live_tool_callback_events.py index 0e5dd3ee..74c8b343 100644 --- a/tests/test_live_tool_callback_events.py +++ b/tests/test_live_tool_callback_events.py @@ -52,6 +52,16 @@ def test_tool_complete_callback_emits_existing_tool_complete_sse_event_with_tool assert "_checkpoint_activity[0] += 1" in block +def test_legacy_progress_events_are_suppressed_when_structured_callbacks_are_wired(): + src = _read("api/streaming.py") + block = _function_block(src, "on_tool") + + assert "event_type in (None, 'tool.started') and 'tool_start_callback' in _agent_params" in block + assert "event_type == 'tool.completed' and 'tool_complete_callback' in _agent_params" in block + assert block.index("'tool_start_callback' in _agent_params") < block.index("put('tool'") + assert block.index("'tool_complete_callback' in _agent_params") < block.index("put('tool_complete'") + + def test_tool_callback_events_keep_existing_frontend_event_contract(): messages = _read("static/messages.js") ui = _read("static/ui.js") From 739c948e74ad81d466f8a5e1288cc5e75ce8510e Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Mon, 18 May 2026 10:46:15 -0400 Subject: [PATCH 03/14] fix(system): allow browser-only dashboard links --- CHANGELOG.md | 3 ++ api/dashboard_probe.py | 57 ++++++++++++++++++++++++++++----- static/index.html | 2 +- static/ui.js | 11 +++++-- tests/test_dashboard_link_ui.py | 9 ++++++ tests/test_dashboard_probe.py | 31 +++++++++++------- 6 files changed, 90 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20fd6277..1f11f60f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ - **PR #2605** by @LumenYoung (refs #2581) — Make the metadata-only `/api/session?messages=0&resolve_model=0` path return the persisted sidecar `message_count` from `Session._metadata_message_count` when no session-index entry exists, so the active-session external-refresh signal still trips on legacy sessions whose sidecar contains externally-appended content. Composed cleanly with #2604 (the legacy-fallback applies only when the reconciled merged count is zero). - **PR #2573** by @espokaos-ops (closes #2510) — Persist session-level approvals when a "Allow for this session" click lands while a stream is active and `_pending` is empty. The approval flow now peeks `_gateway_queues[sid]` to recover the queued `_ApprovalEntry`'s `pattern_keys` so `approve_session()` records the approval; the next dangerous command in the same session no longer asks again. Reduced scope to peek-only per prior review note; the `agent_session_key` round-trip plumbing was dropped (it was dead on the WebUI streaming path). + + +- Allow Settings → System to save public browser-only Official Hermes Dashboard links (for reverse-proxy URLs) without treating them as server-side probe targets. ### Added - **PR #2599** by @Michaelyklam (refs #1925) — Add the Slice 4b `RunnerRuntimeAdapter` facade — a protocol-translator client over a future runner/sidecar backend. The facade delegates `start_run`, `observe_run`, `get_run`, and control calls to an injected runner client, normalizes results into the existing `RunStartResult`/`RunEventStream`/`RunStatus`/`ControlResult` dataclasses, carries explicit `profile`/`workspace`/`model` payload fields, and returns bounded `unsupported` control results without owning `AIAgent`, stream lifecycle, cancel/approval/clarify queues, goal state, or cached-agent table. No route wiring, no default-on runner mode, no public response-shape change. diff --git a/api/dashboard_probe.py b/api/dashboard_probe.py index cc15ef91..95c27345 100644 --- a/api/dashboard_probe.py +++ b/api/dashboard_probe.py @@ -12,7 +12,7 @@ import json import logging import os import urllib.request -from urllib.parse import urlparse +from urllib.parse import urlparse, urlunparse logger = logging.getLogger(__name__) @@ -61,6 +61,41 @@ def normalize_dashboard_url(raw_url: str | None) -> tuple[str, int, str, str] | return normalized_host, port, parsed.scheme, base +def normalize_dashboard_browser_url(raw_url: str | None) -> str: + """Return a safe browser-only dashboard link URL. + + Unlike the server-side probe target, this value is only returned to the + browser for navigation. It may point at a public reverse-proxy hostname, but + it still rejects credentials, paths, query strings, fragments, and non-HTTP + schemes so it cannot hide secrets or script URLs in config. + """ + raw = str(raw_url or "").strip() + if not raw: + return "" + parsed = urlparse(raw) + if parsed.scheme not in {"http", "https"}: + raise ValueError("invalid dashboard URL scheme") + if parsed.username or parsed.password: + raise ValueError("invalid dashboard URL credentials") + if not parsed.hostname: + raise ValueError("invalid dashboard URL host") + if parsed.params or parsed.query or parsed.fragment: + raise ValueError("invalid dashboard URL path") + path = parsed.path or "" + if path not in ("", "/"): + raise ValueError("invalid dashboard URL path") + try: + port = parsed.port + except ValueError as exc: + raise ValueError("invalid dashboard URL port") from exc + netloc = parsed.hostname.lower() + if port is not None: + if not (1 <= port <= 65535): + raise ValueError("invalid dashboard URL port") + netloc = f"{netloc}:{port}" + return urlunparse((parsed.scheme, netloc, "", "", "", "")) + + def _looks_like_official_dashboard(payload: object) -> bool: if not isinstance(payload, dict): return False @@ -132,8 +167,7 @@ def get_dashboard_config(config_data: dict | None = None) -> dict: enabled = "auto" raw_url = str(dashboard_cfg.get("url") or "").strip() if raw_url: - # Normalize before echoing so the UI never displays unsafe/stale values. - _host, _port, _scheme, raw_url = normalize_dashboard_url(raw_url) + raw_url = normalize_dashboard_browser_url(raw_url) return {"enabled": enabled, "url": raw_url} @@ -143,9 +177,7 @@ def save_dashboard_config(payload: dict) -> dict: if enabled not in _DASHBOARD_ENABLED_VALUES: raise ValueError("invalid dashboard enabled mode") raw_url = str((payload or {}).get("url", "") or "").strip() - normalized_url = "" - if raw_url: - _host, _port, _scheme, normalized_url = normalize_dashboard_url(raw_url) + normalized_url = normalize_dashboard_browser_url(raw_url) if raw_url else "" from api import config as webui_config @@ -186,9 +218,13 @@ def get_dashboard_status(config_data: dict | None = None) -> dict: raw_url = dashboard_cfg.get("url") or dashboard_cfg.get("target") or "" try: - override = normalize_dashboard_url(raw_url) + browser_url = normalize_dashboard_browser_url(raw_url) if raw_url else "" except ValueError: return {"running": False, "enabled": enabled, "error": "invalid dashboard url"} + try: + override = normalize_dashboard_url(raw_url) + except ValueError: + override = None targets: list[tuple[str, int, str, str]] if override: @@ -197,8 +233,10 @@ def get_dashboard_status(config_data: dict | None = None) -> dict: targets = [(host, port, "http", _base_url(host, port)) for host, port in DEFAULT_DASHBOARD_TARGETS] if enabled == "always": + if browser_url and not override: + return {"running": True, "enabled": enabled, "url": browser_url, "browser_url": browser_url} host, port, scheme, base = targets[0] - return {"running": True, "enabled": enabled, "host": host, "port": port, "url": base} + return {"running": True, "enabled": enabled, "host": host, "port": port, "url": browser_url or base, "browser_url": browser_url or base} if not _webui_bind_host_allows_auto_probe(): return {"running": False, "enabled": enabled} @@ -207,5 +245,8 @@ def get_dashboard_status(config_data: dict | None = None) -> dict: result = probe_official_dashboard(host, port, timeout=DEFAULT_DASHBOARD_TIMEOUT, scheme=scheme) if result.get("running"): result["enabled"] = enabled + if browser_url: + result["browser_url"] = browser_url + result["url"] = browser_url return result return {"running": False, "enabled": enabled} diff --git a/static/index.html b/static/index.html index 5f1cca5a..d6906e5f 100644 --- a/static/index.html +++ b/static/index.html @@ -1222,7 +1222,7 @@
-
Show a nav-rail link when the official hermes dashboard is reachable. Overrides are restricted to loopback URLs.
+
Show a nav-rail link when the official hermes dashboard is reachable. Public reverse-proxy URLs are stored as browser-only links and are never server-probed.