From 5f9b9c02b29001578b40ebb2a62c2d419b04a4b7 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Fri, 15 May 2026 08:22:44 +0800 Subject: [PATCH 01/13] Fix WebUI stream completion recovery gaps --- CHANGELOG.md | 6 + api/streaming.py | 103 +++++++++++++++++- static/messages.js | 18 +++ static/ui.js | 17 +++ ...issue2262_compression_marker_timeout_ui.py | 39 +++++++ tests/test_notify_on_complete_webui.py | 30 +++++ tests/test_profile_terminal_env.py | 4 + 7 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 tests/test_issue2262_compression_marker_timeout_ui.py create mode 100644 tests/test_notify_on_complete_webui.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 328683ef..7d7e1e4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ - **PR #2165** by @starship-s — Pooled OpenAI Codex quota status surfaced in the Providers panel. Pre-fix, the Providers page presented Codex quota as if there were only one credential/account state, which was misleading when users authenticate through a credential pool with several usable credentials, temporarily exhausted credentials, failed probes, and different reset windows. Now the active provider quota card includes a credential-pool summary (available / exhausted / failed / checked counts), displays the best currently-available pool windows in the collapsed view as "Best of N", and exposes per-credential detail behind an expandable section. Exhausted credentials are intentionally NOT re-probed while their cooldown is active (matches credential-pool selection behavior, avoids generating failed quota calls from a status page). Manual refresh still means "probe now" but transient refresh failures preserve the last known-good snapshot. JWT decode (`_decode_jwt_claims_unverified`) is used only for token-shape classification (Codex OAuth JWT vs raw OpenAI API key), explicitly NOT for authorization — documented in the function docstring. Per-row plan labels only shown when verified account-limit data is available. Concurrent probing capped at `min(_CODEX_POOL_MAX_WORKERS=6, len(probe_items))` so page render time stays bounded on large pools. Transient `None` probe results are NOT cached (only known unavailable/exhausted states are cached); 32-test regression suite covering pool snapshot, concurrent probe, JWT detection, cache invalidation, transient-vs-known cache distinction, and i18n parity across all currently-supported locales. Scoped to OpenAI Codex (the only provider with the credential-pool/account-limit path needed to surface this accurately). +### Fixed + +- WebUI agent turns now inherit `HERMES_SESSION_PLATFORM=webui` and drain matching `notify_on_complete` background-process completions into the next model input. Completion events are filtered by the process session key before delivery, so another tab/session's background process output remains queued for its owner instead of being injected into the wrong conversation. + +- Marker-only preserved-task-list compression sentinels no longer render as standalone assistant responses after stream recovery or timeout paths. If the frontend receives only that internal marker as assistant content, it replaces it with an explicit "No response received after context compression" error and shows an error toast. + ## [v0.51.64] — 2026-05-14 — Release AN (stage-357 — 3-PR small batch — docker_init k8s whoami fallback + PWA manifest session routes (closes #2226) + aux title test coverage) ### Fixed diff --git a/api/streaming.py b/api/streaming.py index 04cba4db..b940e97e 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -583,11 +583,98 @@ def _build_agent_thread_env(profile_runtime_env: dict | None, workspace: str, se 'TERMINAL_CWD': str(workspace), 'HERMES_EXEC_ASK': '1', 'HERMES_SESSION_KEY': session_id, + 'HERMES_SESSION_ID': session_id, + 'HERMES_SESSION_PLATFORM': 'webui', 'HERMES_HOME': profile_home, }) return env +def _format_process_notification(evt: dict) -> str: + """Format a completed background process notification for agent input.""" + if not isinstance(evt, dict): + return '' + if evt.get('type') != 'completion': + return '' + _sid = evt.get('session_id', '') + _cmd = evt.get('command', '') + _exit = evt.get('exit_code', '') + _out = evt.get('output') or '' + if len(_out) > 4000: + _out = _out[:4000] + '\n... (truncated)' + return ( + f"[IMPORTANT: Background process {_sid} completed (exit code {_exit}).\n" + f"Command: {_cmd}\n" + f"Output:\n{_out}]" + ) + + +def _mark_process_completion_consumed(process_registry, process_id: str) -> None: + """Best-effort bridge to the agent registry's private completion marker.""" + try: + with process_registry._lock: + process_registry._completion_consumed.add(process_id) + except Exception: + logger.debug("Failed to mark process completion consumed", exc_info=True) + + +def _drain_webui_process_notifications(session_id: str) -> list[str]: + """Return completion notifications that belong to this WebUI session. + + The agent registry completion queue is process-wide and events do not carry + the WebUI session key directly. Look up the live process session before + delivery so completions from other tabs remain queued for their owners. + """ + if not session_id: + return [] + try: + from tools.process_registry import process_registry + except Exception: + return [] + + notifications: list[str] = [] + skipped_events: list[dict] = [] + completion_queue = getattr(process_registry, 'completion_queue', None) + if completion_queue is None: + return [] + + while True: + try: + evt = completion_queue.get_nowait() + except queue.Empty: + break + except Exception: + logger.debug("Failed to drain process completion queue", exc_info=True) + break + + evt_sid = str(evt.get('session_id') or '') if isinstance(evt, dict) else '' + if not evt_sid: + skipped_events.append(evt) + continue + try: + if process_registry.is_completion_consumed(evt_sid): + continue + proc = process_registry.get(evt_sid) + except Exception: + proc = None + if getattr(proc, 'session_key', None) != session_id: + skipped_events.append(evt) + continue + + notification = _format_process_notification(evt) + if notification: + notifications.append(notification) + _mark_process_completion_consumed(process_registry, evt_sid) + + for evt in skipped_events: + try: + completion_queue.put(evt) + except Exception: + logger.debug("Failed to requeue process completion event", exc_info=True) + break + return notifications + + def _attachment_name(att) -> str: if isinstance(att, dict): return str(att.get('name') or att.get('filename') or att.get('path') or '').strip() @@ -2254,6 +2341,8 @@ def _run_agent_streaming( old_cwd = None old_exec_ask = None old_session_key = None + old_session_id = None + old_session_platform = None old_hermes_home = None old_profile_env = {} @@ -2504,11 +2593,15 @@ def _run_agent_streaming( old_cwd = os.environ.get('TERMINAL_CWD') old_exec_ask = os.environ.get('HERMES_EXEC_ASK') old_session_key = os.environ.get('HERMES_SESSION_KEY') + old_session_id = os.environ.get('HERMES_SESSION_ID') + old_session_platform = os.environ.get('HERMES_SESSION_PLATFORM') old_hermes_home = os.environ.get('HERMES_HOME') os.environ.update(_profile_runtime_env) os.environ['TERMINAL_CWD'] = str(s.workspace) os.environ['HERMES_EXEC_ASK'] = '1' os.environ['HERMES_SESSION_KEY'] = session_id + os.environ['HERMES_SESSION_ID'] = session_id + os.environ['HERMES_SESSION_PLATFORM'] = 'webui' if _profile_home: os.environ['HERMES_HOME'] = _profile_home # Patch module-level caches to match the active profile. @@ -3260,7 +3353,11 @@ def _run_agent_streaming( ) _ckpt_thread.start() - user_message = _build_native_multimodal_message(workspace_ctx, msg_text, attachments, workspace, cfg=_cfg) + _process_notifications = _drain_webui_process_notifications(session_id) + _agent_msg_text = msg_text + if _process_notifications: + _agent_msg_text = "\n\n".join([*_process_notifications, msg_text]).strip() + user_message = _build_native_multimodal_message(workspace_ctx, _agent_msg_text, attachments, workspace, cfg=_cfg) result = agent.run_conversation( user_message=user_message, system_message=workspace_system_msg, @@ -4184,6 +4281,10 @@ def _run_agent_streaming( else: os.environ['HERMES_EXEC_ASK'] = old_exec_ask if old_session_key is None: os.environ.pop('HERMES_SESSION_KEY', None) else: os.environ['HERMES_SESSION_KEY'] = old_session_key + if old_session_id is None: os.environ.pop('HERMES_SESSION_ID', None) + else: os.environ['HERMES_SESSION_ID'] = old_session_id + if old_session_platform is None: os.environ.pop('HERMES_SESSION_PLATFORM', None) + else: os.environ['HERMES_SESSION_PLATFORM'] = old_session_platform if old_hermes_home is None: os.environ.pop('HERMES_HOME', None) else: os.environ['HERMES_HOME'] = old_hermes_home diff --git a/static/messages.js b/static/messages.js index 66a3c90d..8f57d13a 100644 --- a/static/messages.js +++ b/static/messages.js @@ -481,6 +481,20 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ clearInflightState(activeSid); _clearActivePaneInflightIfOwner(); } + function _isMarkerOnlyAssistantMessage(m){ + if(!m||m.role!=='assistant') return false; + const text=String(typeof msgContent==='function'?msgContent(m):(m.content||'')); + return typeof _isPreservedCompressionTaskListMarkerOnlyText==='function' + && _isPreservedCompressionTaskListMarkerOnlyText(text); + } + function _replaceMarkerOnlyAssistantWithStreamError(messages){ + if(!Array.isArray(messages)) return false; + const msg=[...messages].reverse().find(m=>m&&m.role==='assistant'); + if(!_isMarkerOnlyAssistantMessage(msg)) return false; + msg.content='**Error:** No response received after context compression. Please retry.'; + msg.provider_details='The only assistant text returned for this turn was the internal preserved-task-list compression marker, so the WebUI replaced it with an explicit error instead of rendering the marker as a model response.'; + return true; + } function _setActivePaneIdleIfOwner(){ if(_isActiveSession()||!S.session||!INFLIGHT[S.session.session_id]){ setBusy(false); @@ -1358,6 +1372,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ localStorage.setItem('hermes-webui-session',S.session.session_id); if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id); } + const _markerOnlyAssistantError=_replaceMarkerOnlyAssistantWithStreamError(S.messages); if( window._compressionUi&&window._compressionUi.automatic&& window._compressionUi.sessionId===activeSid&& @@ -1429,6 +1444,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ S.busy=false; // No-reply guard (#373): if agent returned nothing, show inline error if(!S.messages.some(m=>m.role==='assistant'&&String(m.content||'').trim())&&!assistantText){removeThinking();S.messages.push({role:'assistant',content:'**No response received.** Check your API key and model selection.'});} + if(_markerOnlyAssistantError&&typeof showToast==='function') showToast('No response received after context compression. Please retry.',5000,'error'); if(isSessionViewed) _markSessionViewed(completedSid, completedSession.message_count ?? S.messages.length); syncTopbar();renderMessages({preserveScroll:true}); if(shouldFollowOnDone&&typeof scrollToBottom==='function') scrollToBottom(); @@ -1713,6 +1729,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ localStorage.setItem('hermes-webui-session',S.session.session_id); if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id); } + const _markerOnlyAssistantError=_replaceMarkerOnlyAssistantWithStreamError(S.messages); + if(_markerOnlyAssistantError&&typeof showToast==='function') showToast('No response received after context compression. Please retry.',5000,'error'); const hasMessageToolMetadata=S.messages.some(m=>{ if(!m||m.role!=='assistant') return false; // Recognize both the standard `tool_calls` (used by completed assistant diff --git a/static/ui.js b/static/ui.js index f270909f..af7fff59 100644 --- a/static/ui.js +++ b/static/ui.js @@ -4755,11 +4755,25 @@ function _isContextCompactionMessage(m){ const text=msgContent(m)||String(m.content||''); return /^\s*\[context compaction/i.test(text) || /^\s*context compaction/i.test(text); } +function _isPreservedCompressionTaskListMarkerText(text){ + return /^\s*\[your active task list was preserved across context compression\]/i.test(String(text||'')); +} +function _isPreservedCompressionTaskListMarkerOnlyText(text){ + return _isPreservedCompressionTaskListMarkerText(text) + && !String(text||'') + .replace(/^\s*\[your active task list was preserved across context compression\]\s*/i,'') + .trim(); +} function _isPreservedCompressionTaskListMessage(m){ if(!m||m.role!=='user') return false; const text=msgContent(m)||String(m.content||''); return /^\s*\[your active task list was preserved across context compression\]/i.test(text); } +function _isMarkerOnlyAssistantCompressionMessage(m){ + if(!m||m.role!=='assistant') return false; + const text=msgContent(m)||String(m.content||''); + return _isPreservedCompressionTaskListMarkerOnlyText(text); +} function _preservedCompressionTaskListPreview(text){ const body=String(text||'') .replace(/^\s*\[your active task list was preserved across context compression\]\s*/i,'') @@ -5338,6 +5352,9 @@ function renderMessages(options){ } } const isUser=m.role==='user'; + if(!isUser&&_isMarkerOnlyAssistantCompressionMessage(m)){ + content='**Error:** No response received after context compression. Please retry.'; + } const displayContent=isUser?_stripWorkspaceDisplayPrefix(content):content; const isLastAssistant=!isUser&&vi===renderVisWithIdx.length-1; let filesHtml=''; diff --git a/tests/test_issue2262_compression_marker_timeout_ui.py b/tests/test_issue2262_compression_marker_timeout_ui.py new file mode 100644 index 00000000..780370d9 --- /dev/null +++ b/tests/test_issue2262_compression_marker_timeout_ui.py @@ -0,0 +1,39 @@ +from pathlib import Path + + +def _read(path: str) -> str: + return Path(path).read_text(encoding="utf-8") + + +def test_preserved_task_list_marker_only_helper_is_strict(): + src = _read("static/ui.js") + + assert "function _isPreservedCompressionTaskListMarkerOnlyText" in src + start = src.find("function _isPreservedCompressionTaskListMarkerOnlyText") + end = src.find("function _isPreservedCompressionTaskListMessage", start) + helper = src[start:end] + + assert "_isPreservedCompressionTaskListMarkerText(text)" in helper + assert ".replace(/^\\s*\\[your active task list was preserved across context compression\\]" in helper + assert ".trim()" in helper + + +def test_marker_only_assistant_message_renders_as_error_not_model_text(): + src = _read("static/ui.js") + + assert "function _isMarkerOnlyAssistantCompressionMessage" in src + assert "m.role!=='assistant'" in src + assert "_isPreservedCompressionTaskListMarkerOnlyText(text)" in src + assert "if(!isUser&&_isMarkerOnlyAssistantCompressionMessage(m))" in src + assert "content='**Error:** No response received after context compression. Please retry.'" in src + + +def test_done_and_restore_replace_marker_only_assistant_with_error_toast(): + src = _read("static/messages.js") + + assert "function _replaceMarkerOnlyAssistantWithStreamError(messages)" in src + assert "_isMarkerOnlyAssistantMessage(msg)" in src + assert "msg.content='**Error:** No response received after context compression. Please retry.'" in src + assert "internal preserved-task-list compression marker" in src + assert "_markerOnlyAssistantError=_replaceMarkerOnlyAssistantWithStreamError(S.messages)" in src + assert "showToast('No response received after context compression. Please retry.',5000,'error')" in src diff --git a/tests/test_notify_on_complete_webui.py b/tests/test_notify_on_complete_webui.py new file mode 100644 index 00000000..8b8e88b6 --- /dev/null +++ b/tests/test_notify_on_complete_webui.py @@ -0,0 +1,30 @@ +from pathlib import Path + + +def test_webui_drains_only_matching_background_completion_events(): + src = Path("api/streaming.py").read_text(encoding="utf-8") + + assert "def _drain_webui_process_notifications(session_id: str)" in src + assert "from tools.process_registry import process_registry" in src + assert "proc = process_registry.get(evt_sid)" in src + assert "getattr(proc, 'session_key', None) != session_id" in src + assert "skipped_events.append(evt)" in src + assert "completion_queue.put(evt)" in src + + +def test_webui_injects_process_notifications_without_persisting_them_as_user_text(): + src = Path("api/streaming.py").read_text(encoding="utf-8") + + assert "_process_notifications = _drain_webui_process_notifications(session_id)" in src + assert "[*_process_notifications, msg_text]" in src + assert "_build_native_multimodal_message(workspace_ctx, _agent_msg_text" in src + assert "persist_user_message=msg_text" in src + + +def test_webui_sets_gateway_session_platform_for_background_watchers(): + src = Path("api/streaming.py").read_text(encoding="utf-8") + + assert "'HERMES_SESSION_PLATFORM': 'webui'" in src + assert "os.environ['HERMES_SESSION_PLATFORM'] = 'webui'" in src + assert "old_session_platform = os.environ.get('HERMES_SESSION_PLATFORM')" in src + assert "os.environ.pop('HERMES_SESSION_PLATFORM', None)" in src diff --git a/tests/test_profile_terminal_env.py b/tests/test_profile_terminal_env.py index ebf72596..1614d6b6 100644 --- a/tests/test_profile_terminal_env.py +++ b/tests/test_profile_terminal_env.py @@ -78,6 +78,8 @@ def test_streaming_thread_env_allows_profile_terminal_cwd_override(): "TERMINAL_CWD": "/profile/config/cwd", "HERMES_EXEC_ASK": "0", "HERMES_SESSION_KEY": "old-session", + "HERMES_SESSION_ID": "old-session", + "HERMES_SESSION_PLATFORM": "cli", "HERMES_HOME": "/old/profile/home", "TERMINAL_ENV": "ssh", }, @@ -89,5 +91,7 @@ def test_streaming_thread_env_allows_profile_terminal_cwd_override(): assert env["TERMINAL_CWD"] == "/active/workspace" assert env["HERMES_EXEC_ASK"] == "1" assert env["HERMES_SESSION_KEY"] == "active-session" + assert env["HERMES_SESSION_ID"] == "active-session" + assert env["HERMES_SESSION_PLATFORM"] == "webui" assert env["HERMES_HOME"] == "/active/profile/home" assert env["TERMINAL_ENV"] == "ssh" From aa1c7c24f4d639cb82948e278d4c0ee16809ad9e Mon Sep 17 00:00:00 2001 From: starship-s <45587122+starship-s@users.noreply.github.com> Date: Fri, 15 May 2026 03:02:42 -0600 Subject: [PATCH 02/13] fix(profiles): route background aux workers via session profile --- api/profiles.py | 49 ++++++++++ api/routes.py | 77 +++++++++++++++- api/streaming.py | 102 ++++++++++++++------ tests/test_sprint46.py | 66 +++++++++++++ tests/test_title_aux_routing.py | 100 ++++++++++++++++++++ tests/test_update_banner_fixes.py | 148 ++++++++++++++++++++++++++++++ 6 files changed, 514 insertions(+), 28 deletions(-) diff --git a/api/profiles.py b/api/profiles.py index 35440b47..577c9c3a 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -42,6 +42,24 @@ _tls = threading.local() _SKILL_HOME_MODULES = ("tools.skills_tool", "tools.skill_manager_tool") +def snapshot_skill_home_modules() -> dict[str, dict[str, object]]: + """Snapshot imported skill-module path globals before a temporary patch.""" + snapshot: dict[str, dict[str, object]] = {} + for module_name in _SKILL_HOME_MODULES: + module = sys.modules.get(module_name) + if module is None: + snapshot[module_name] = {"module_present": False} + continue + snapshot[module_name] = { + "module_present": True, + "has_HERMES_HOME": hasattr(module, "HERMES_HOME"), + "HERMES_HOME": getattr(module, "HERMES_HOME", None), + "has_SKILLS_DIR": hasattr(module, "SKILLS_DIR"), + "SKILLS_DIR": getattr(module, "SKILLS_DIR", None), + } + return snapshot + + def patch_skill_home_modules(home: Path) -> None: """Patch imported skill modules that cache HERMES_HOME at import time.""" for module_name in _SKILL_HOME_MODULES: @@ -55,6 +73,37 @@ def patch_skill_home_modules(home: Path) -> None: logger.debug("Failed to patch %s module", module_name) +def restore_skill_home_modules(snapshot: dict[str, dict[str, object]]) -> None: + """Restore skill-module globals captured by snapshot_skill_home_modules().""" + for module_name, values in snapshot.items(): + module = sys.modules.get(module_name) + if not values.get("module_present"): + if module is not None: + sys.modules.pop(module_name, None) + parent_name, _, child_name = module_name.rpartition(".") + parent = sys.modules.get(parent_name) + if parent is not None: + try: + delattr(parent, child_name) + except AttributeError: + pass + continue + if module is None: + continue + for attr in ("HERMES_HOME", "SKILLS_DIR"): + has_attr = bool(values.get(f"has_{attr}")) + try: + if has_attr: + setattr(module, attr, values.get(attr)) + else: + try: + delattr(module, attr) + except AttributeError: + pass + except AttributeError: + logger.debug("Failed to restore %s.%s", module_name, attr) + + def _unwrap_profile_home_to_base(home: Path) -> Path: """Return the base Hermes home when *home* is already a named profile dir.""" if home.parent.name == 'profiles': diff --git a/api/routes.py b/api/routes.py index 58a87345..4bed255a 100644 --- a/api/routes.py +++ b/api/routes.py @@ -20,8 +20,9 @@ import threading import time import uuid import re +from types import SimpleNamespace from pathlib import Path -from contextlib import closing +from contextlib import closing, contextmanager from urllib.parse import parse_qs from api.agent_sessions import ( MESSAGING_SOURCES, @@ -71,6 +72,57 @@ _CSP_REPORT_RATE_LIMIT_MAX = 100 _CSP_REPORT_MAX_BODY_BYTES = 64 * 1024 +@contextmanager +def _profile_env_for_background_worker(session, purpose: str = "background worker"): + """Temporarily route agent/config reads through a session's profile. + + Detached WebUI workers run in their own threads, so they do not inherit the + streaming thread's profile-scoped HERMES_HOME/runtime environment. Any + worker that calls hermes-agent config/runtime helpers must set the session + profile explicitly or it may read the default profile instead. + """ + profile = str(getattr(session, "profile", "") or "").strip() + if not profile or profile == "default": + yield + return + + try: + from api.profiles import ( + get_hermes_home_for_profile, + get_profile_runtime_env, + patch_skill_home_modules, + restore_skill_home_modules, + snapshot_skill_home_modules, + ) + from api.streaming import _ENV_LOCK + + profile_home_path = Path(get_hermes_home_for_profile(profile)) + runtime_env = get_profile_runtime_env(profile_home_path) + except Exception: + yield + return + + env_keys = set(runtime_env.keys()) | {"HERMES_HOME"} + with _ENV_LOCK: + old_env = {key: os.environ.get(key) for key in env_keys} + skill_home_snapshot = snapshot_skill_home_modules() + try: + os.environ.update(runtime_env) + os.environ["HERMES_HOME"] = str(profile_home_path) + try: + patch_skill_home_modules(profile_home_path) + except Exception: + logger.debug("Failed to patch skill modules for %s profile %s", purpose, profile) + yield + finally: + for key, old_value in old_env.items(): + if old_value is None: + os.environ.pop(key, None) + else: + os.environ[key] = old_value + restore_skill_home_modules(skill_home_snapshot) + + # ── Profile-scoped session/project filtering (#1611, #1614) ──────────────── # # Sessions and projects are stored in the WebUI sidecar without per-row @@ -5443,6 +5495,19 @@ def handle_post(handler, parsed) -> bool: target = body.get("target") if isinstance(body, dict) else None def _llm_update_summary(system_prompt: str, user_prompt: str) -> str: + try: + from api.profiles import get_active_profile_name + active_profile = get_active_profile_name() or "default" + except Exception: + active_profile = "default" + + with _profile_env_for_background_worker( + SimpleNamespace(profile=active_profile), + "update summary", + ): + return _llm_update_summary_with_profile_env(system_prompt, user_prompt) + + def _llm_update_summary_with_profile_env(system_prompt: str, user_prompt: str) -> str: from api.config import ( get_effective_default_model, resolve_model_provider, @@ -8268,7 +8333,15 @@ def _manual_compression_status_payload(job): def _run_manual_compression_job(sid, body): memory_handler = _ManualCompressionMemoryHandler() try: - _handle_session_compress(memory_handler, body) + try: + session = get_session(sid) + except KeyError: + session = None + if session is not None: + with _profile_env_for_background_worker(session, "manual compression"): + _handle_session_compress(memory_handler, body) + else: + _handle_session_compress(memory_handler, body) status = int(memory_handler.status or 500) payload = memory_handler.payload() with _MANUAL_COMPRESSION_JOBS_LOCK: diff --git a/api/streaming.py b/api/streaming.py index 04cba4db..fc117de5 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -41,7 +41,7 @@ from api.turn_journal import append_turn_journal_event_for_stream # concurrent runs of the SAME session, but two DIFFERENT sessions can still # interleave their os.environ writes. This global lock serializes the env # save/restore around the entire agent run. -_ENV_LOCK = threading.Lock() +_ENV_LOCK = threading.RLock() def _prewarm_skill_tool_modules(): @@ -1445,6 +1445,54 @@ def _is_generic_fallback_title(title: str) -> bool: return str(title or '').strip().lower() in {'conversation topic'} +@contextlib.contextmanager +def _profile_env_for_title_worker(session): + """Temporarily route auxiliary title-generation config through the session profile. + + Background title workers run in their own thread and do not inherit the + streaming thread's thread-local/profile context. Without setting + HERMES_HOME here, hermes-agent's auxiliary_client.load_config() can read + the default profile and use the wrong title_generation model. + """ + profile = str(getattr(session, 'profile', '') or '').strip() + if not profile or profile == 'default': + yield + return + try: + from api.profiles import ( + get_hermes_home_for_profile, + get_profile_runtime_env, + patch_skill_home_modules, + restore_skill_home_modules, + snapshot_skill_home_modules, + ) + profile_home_path = Path(get_hermes_home_for_profile(profile)) + runtime_env = get_profile_runtime_env(profile_home_path) + except Exception: + yield + return + + env_keys = set(runtime_env.keys()) | {'HERMES_HOME'} + with _ENV_LOCK: + old_env = {key: os.environ.get(key) for key in env_keys} + skill_home_snapshot = snapshot_skill_home_modules() + try: + os.environ.update(runtime_env) + os.environ['HERMES_HOME'] = str(profile_home_path) + try: + patch_skill_home_modules(profile_home_path) + except Exception: + logger.debug("Failed to patch skill modules for background title profile %s", profile) + yield + finally: + for key, old_value in old_env.items(): + if old_value is None: + os.environ.pop(key, None) + else: + os.environ[key] = old_value + restore_skill_home_modules(skill_home_snapshot) + + def _run_background_title_update(session_id: str, user_text: str, assistant_text: str, placeholder_title: str, put_event, agent=None): """Generate and publish a better title after `done`, then end the stream.""" try: @@ -1468,24 +1516,25 @@ def _run_background_title_update(session_id: str, user_text: str, assistant_text if not still_auto: _put_title_status(put_event, session_id, 'skipped', 'manual_title', current) return - aux_title_configured = _aux_title_configured() - if agent and not aux_title_configured: - next_title, llm_status, raw_preview = _generate_llm_session_title_for_agent(agent, user_text, assistant_text) - if not next_title and llm_status in ('llm_error', 'llm_invalid'): - next_title, llm_status, raw_preview = _generate_llm_session_title_via_aux(user_text, assistant_text, agent=agent, use_agent_model=True) - else: - next_title, llm_status, raw_preview = _generate_llm_session_title_via_aux(user_text, assistant_text) - if not next_title and agent and llm_status in ('llm_error_aux', 'llm_invalid_aux'): + with _profile_env_for_title_worker(s): + aux_title_configured = _aux_title_configured() + if agent and not aux_title_configured: next_title, llm_status, raw_preview = _generate_llm_session_title_for_agent(agent, user_text, assistant_text) - source = llm_status - if not next_title: - fallback_title = _fallback_title_from_exchange(user_text, assistant_text) - if fallback_title and not _is_generic_fallback_title(fallback_title): - logger.debug("Using local fallback for session title generation") - next_title = fallback_title - source = 'fallback' - elif fallback_title: - logger.debug("Skipping generic local fallback for session title generation: %r", fallback_title) + if not next_title and llm_status in ('llm_error', 'llm_invalid'): + next_title, llm_status, raw_preview = _generate_llm_session_title_via_aux(user_text, assistant_text, agent=agent, use_agent_model=True) + else: + next_title, llm_status, raw_preview = _generate_llm_session_title_via_aux(user_text, assistant_text) + if not next_title and agent and llm_status in ('llm_error_aux', 'llm_invalid_aux'): + next_title, llm_status, raw_preview = _generate_llm_session_title_for_agent(agent, user_text, assistant_text) + source = llm_status + if not next_title: + fallback_title = _fallback_title_from_exchange(user_text, assistant_text) + if fallback_title and not _is_generic_fallback_title(fallback_title): + logger.debug("Using local fallback for session title generation") + next_title = fallback_title + source = 'fallback' + elif fallback_title: + logger.debug("Skipping generic local fallback for session title generation: %r", fallback_title) fallback_reason = ( f'local_summary:{llm_status}' if source == 'fallback' and llm_status @@ -1548,15 +1597,16 @@ def _run_background_title_refresh(session_id: str, user_text: str, assistant_tex return if not effective or effective in ('Untitled', 'New Chat'): return - aux_title_configured = _aux_title_configured() - if agent and not aux_title_configured: - next_title, llm_status, raw_preview = _generate_llm_session_title_for_agent(agent, user_text, assistant_text) - if not next_title and llm_status in ('llm_error', 'llm_invalid'): - next_title, llm_status, raw_preview = _generate_llm_session_title_via_aux(user_text, assistant_text, agent=agent, use_agent_model=True) - else: - next_title, llm_status, raw_preview = _generate_llm_session_title_via_aux(user_text, assistant_text) - if not next_title and agent and llm_status in ('llm_error_aux', 'llm_invalid_aux'): + with _profile_env_for_title_worker(s): + aux_title_configured = _aux_title_configured() + if agent and not aux_title_configured: next_title, llm_status, raw_preview = _generate_llm_session_title_for_agent(agent, user_text, assistant_text) + if not next_title and llm_status in ('llm_error', 'llm_invalid'): + next_title, llm_status, raw_preview = _generate_llm_session_title_via_aux(user_text, assistant_text, agent=agent, use_agent_model=True) + else: + next_title, llm_status, raw_preview = _generate_llm_session_title_via_aux(user_text, assistant_text) + if not next_title and agent and llm_status in ('llm_error_aux', 'llm_invalid_aux'): + next_title, llm_status, raw_preview = _generate_llm_session_title_for_agent(agent, user_text, assistant_text) if not next_title: _put_title_status(put_event, session_id, 'refresh_skipped', llm_status or 'empty', effective, raw_preview) return diff --git a/tests/test_sprint46.py b/tests/test_sprint46.py index e502ea9b..0db0ffa2 100644 --- a/tests/test_sprint46.py +++ b/tests/test_sprint46.py @@ -5,6 +5,7 @@ Sprint 46 Tests: manual session compression with optional focus topic. import contextlib import io import json +import os import sys import threading import time @@ -406,6 +407,71 @@ def test_session_compress_async_reports_stream_state_guard(monkeypatch, cleanup_ assert get_session(sid).active_stream_id == "stream-concurrent" +def test_manual_compress_worker_uses_session_profile_env(monkeypatch, tmp_path, cleanup_test_sessions): + import api.profiles as profiles + import api.routes as routes + + class EnvAssertingAgent: + seen_env = None + + def __init__(self, **kwargs): + skill_module = sys.modules.get("tools.skills_tool") + EnvAssertingAgent.seen_env = { + "HERMES_HOME": os.environ.get("HERMES_HOME"), + "HERMES_TEST_PROFILE_ENV": os.environ.get("HERMES_TEST_PROFILE_ENV"), + "SKILL_MODULE_HOME": getattr(skill_module, "HERMES_HOME", None), + "SKILL_MODULE_DIR": getattr(skill_module, "SKILLS_DIR", None), + } + self.context_compressor = _FakeCompressor() + + created = cleanup_test_sessions + sid = _make_session() + created.append(sid) + session = get_session(sid) + session.profile = "work" + session.model_provider = "profile-provider" + session.save(touch_updated_at=False) + + profile_home = tmp_path / "work-profile-home" + fake_skill_module = types.ModuleType("tools.skills_tool") + setattr(fake_skill_module, "HERMES_HOME", "default-home") + setattr(fake_skill_module, "SKILLS_DIR", "default-home/skills") + monkeypatch.setitem(sys.modules, "tools.skills_tool", fake_skill_module) + monkeypatch.setattr(profiles, "get_hermes_home_for_profile", lambda profile: profile_home) + monkeypatch.setattr( + profiles, + "get_profile_runtime_env", + lambda home: {"HERMES_TEST_PROFILE_ENV": "work-runtime"}, + ) + monkeypatch.setenv("HERMES_HOME", "default-home") + monkeypatch.delenv("HERMES_TEST_PROFILE_ENV", raising=False) + _install_fake_compression_runtime(monkeypatch, EnvAssertingAgent) + + with routes._MANUAL_COMPRESSION_JOBS_LOCK: + routes._MANUAL_COMPRESSION_JOBS[sid] = { + "session_id": sid, + "focus_topic": None, + "status": "running", + "started_at": time.time(), + "updated_at": time.time(), + } + + routes._run_manual_compression_job(sid, {"session_id": sid}) + + assert EnvAssertingAgent.seen_env == { + "HERMES_HOME": str(profile_home), + "HERMES_TEST_PROFILE_ENV": "work-runtime", + "SKILL_MODULE_HOME": profile_home, + "SKILL_MODULE_DIR": profile_home / "skills", + } + assert str(getattr(fake_skill_module, "HERMES_HOME")) == "default-home" + assert str(getattr(fake_skill_module, "SKILLS_DIR")) == "default-home/skills" + assert os.environ.get("HERMES_HOME") == "default-home" + assert os.environ.get("HERMES_TEST_PROFILE_ENV") is None + with routes._MANUAL_COMPRESSION_JOBS_LOCK: + assert routes._MANUAL_COMPRESSION_JOBS[sid]["status"] == "done" + + def test_static_commands_js_registers_compress_alias(cleanup_test_sessions): from pathlib import Path diff --git a/tests/test_title_aux_routing.py b/tests/test_title_aux_routing.py index 373f0747..c1c90a43 100644 --- a/tests/test_title_aux_routing.py +++ b/tests/test_title_aux_routing.py @@ -6,6 +6,7 @@ Covers: - aux→agent fallback triggers on 'llm_invalid_aux' status - _aux_title_timeout rejects zero, negative, and non-numeric values """ +import os import sys import types import unittest @@ -378,6 +379,105 @@ class TestReasoningModelTitleGeneration(unittest.TestCase): mock_session.save.assert_not_called() +class TestBackgroundTitleProfileRouting(unittest.TestCase): + def test_skill_home_snapshot_removes_modules_imported_during_context(self): + """Modules first imported inside a temporary profile context must not leak.""" + import api.profiles as profiles + + original_parent = sys.modules.get('tools') + original_skill_module = sys.modules.get('tools.skills_tool') + original_manager_module = sys.modules.get('tools.skill_manager_tool') + + sys.modules.pop('tools.skills_tool', None) + sys.modules.pop('tools.skill_manager_tool', None) + tools_parent = types.ModuleType('tools') + sys.modules['tools'] = tools_parent + try: + snapshot = profiles.snapshot_skill_home_modules() + + imported_during_context = types.ModuleType('tools.skills_tool') + setattr(imported_during_context, 'HERMES_HOME', 'profile-home') + setattr(imported_during_context, 'SKILLS_DIR', 'profile-home/skills') + sys.modules['tools.skills_tool'] = imported_during_context + setattr(tools_parent, 'skills_tool', imported_during_context) + + profiles.restore_skill_home_modules(snapshot) + + self.assertNotIn('tools.skills_tool', sys.modules) + self.assertFalse(hasattr(tools_parent, 'skills_tool')) + finally: + sys.modules.pop('tools.skills_tool', None) + sys.modules.pop('tools.skill_manager_tool', None) + if original_parent is None: + sys.modules.pop('tools', None) + else: + sys.modules['tools'] = original_parent + if original_skill_module is not None: + sys.modules['tools.skills_tool'] = original_skill_module + if original_manager_module is not None: + sys.modules['tools.skill_manager_tool'] = original_manager_module + + @patch('api.streaming._aux_title_configured', return_value=True) + @patch('api.streaming.get_session') + def test_background_title_generation_uses_session_profile_home( + self, mock_get_session, mock_configured, + ): + """A background title worker for a non-default profile must resolve aux config from that profile.""" + from api.streaming import _run_background_title_update + + mock_session = MagicMock() + mock_session.title = 'Untitled' + mock_session.profile = 'work' + mock_session.llm_title_generated = False + mock_session.messages = [ + {'role': 'user', 'content': 'This is a test message'}, + {'role': 'assistant', 'content': 'Received.'}, + ] + mock_get_session.return_value = mock_session + + captured = {} + + original_skill_module = sys.modules.get('tools.skills_tool') + fake_skill_module = types.ModuleType('tools.skills_tool') + setattr(fake_skill_module, 'HERMES_HOME', 'default-home') + setattr(fake_skill_module, 'SKILLS_DIR', 'default-home/skills') + sys.modules['tools.skills_tool'] = fake_skill_module + + def fake_aux_title(*args, **kwargs): + captured['hermes_home'] = os.environ.get('HERMES_HOME') + captured['skill_module_home'] = getattr(fake_skill_module, 'HERMES_HOME') + captured['skill_module_dir'] = getattr(fake_skill_module, 'SKILLS_DIR') + return ('Profile Routed Title', 'llm_aux', '') + + events = [] + try: + with patch('api.profiles.get_hermes_home_for_profile', return_value='profile-home'): + with patch('api.streaming._generate_llm_session_title_via_aux', side_effect=fake_aux_title): + with patch.dict(os.environ, {'HERMES_HOME': 'default-home'}, clear=False): + _run_background_title_update( + session_id='profile-title-session', + user_text='This is a test message', + assistant_text='Received.', + placeholder_title='Untitled', + put_event=lambda event_type, data: events.append((event_type, data)), + agent=None, + ) + captured['restored_hermes_home'] = os.environ.get('HERMES_HOME') + finally: + if original_skill_module is None: + sys.modules.pop('tools.skills_tool', None) + else: + sys.modules['tools.skills_tool'] = original_skill_module + + self.assertEqual(captured.get('hermes_home'), 'profile-home') + self.assertEqual(str(captured.get('skill_module_home')), 'profile-home') + self.assertEqual(str(captured.get('skill_module_dir')), 'profile-home/skills') + self.assertEqual(captured.get('restored_hermes_home'), 'default-home') + self.assertEqual(getattr(fake_skill_module, 'HERMES_HOME'), 'default-home') + self.assertEqual(getattr(fake_skill_module, 'SKILLS_DIR'), 'default-home/skills') + self.assertEqual(mock_session.title, 'Profile Routed Title') + + class TestAuxTitleTimeoutEdgeCases(unittest.TestCase): """_aux_title_timeout must reject zero, negative, and non-numeric values.""" diff --git a/tests/test_update_banner_fixes.py b/tests/test_update_banner_fixes.py index a9e57f58..23b54b6b 100644 --- a/tests/test_update_banner_fixes.py +++ b/tests/test_update_banner_fixes.py @@ -18,6 +18,9 @@ import threading import time import sys import os +import io +import json +import types REPO = pathlib.Path(__file__).parent.parent @@ -428,9 +431,154 @@ class TestUpdateSummaryRouteModelSelection: assert '"compression"' in src assert '"update_summary"' not in src assert 'main_runtime=main_runtime' in src + assert '_profile_env_for_background_worker' in src + assert 'get_active_profile_name' in src assert 'update summary auxiliary model failed; falling back to main model' in src assert 'from run_agent import AIAgent' in src + def test_summary_route_auxiliary_model_uses_active_profile_env(self, monkeypatch, tmp_path): + import api.config as cfg + import api.profiles as profiles + import api.routes as routes + import api.updates as updates + + class FakeHandler: + def __init__(self, payload): + raw = json.dumps(payload).encode('utf-8') + self.headers = {'Content-Length': str(len(raw))} + self.rfile = io.BytesIO(raw) + self.wfile = io.BytesIO() + self.status = None + + def send_response(self, status): + self.status = status + + def send_header(self, _key, _value): + pass + + def end_headers(self): + pass + + def response_payload(self): + return json.loads(self.wfile.getvalue().decode('utf-8')) + + captured = {} + profile_home = tmp_path / 'profiles' / 'work' + fake_skill_module = types.ModuleType('tools.skills_tool') + setattr(fake_skill_module, 'HERMES_HOME', 'default-home') + setattr(fake_skill_module, 'SKILLS_DIR', 'default-home/skills') + monkeypatch.setitem(sys.modules, 'tools.skills_tool', fake_skill_module) + + monkeypatch.setattr(profiles, 'get_hermes_home_for_profile', lambda profile: profile_home) + monkeypatch.setattr( + profiles, + 'get_profile_runtime_env', + lambda home: {'HERMES_TEST_PROFILE_ENV': 'work-runtime'}, + ) + monkeypatch.setattr(cfg, 'get_effective_default_model', lambda: 'openai/test-main') + + def fake_resolve_model_provider(model): + captured['model_resolution_env'] = { + 'HERMES_HOME': os.environ.get('HERMES_HOME'), + 'HERMES_TEST_PROFILE_ENV': os.environ.get('HERMES_TEST_PROFILE_ENV'), + } + return model, 'openai', 'https://example.test/v1' + + monkeypatch.setattr(cfg, 'resolve_model_provider', fake_resolve_model_provider) + monkeypatch.setattr(cfg, 'resolve_custom_provider_connection', lambda provider: (None, None)) + + fake_runtime_provider = types.ModuleType('hermes_cli.runtime_provider') + fake_runtime_provider.resolve_runtime_provider = lambda requested=None: { + 'api_key': 'fake-key', + 'provider': requested or 'openai', + 'base_url': 'https://example.test/v1', + } + fake_hermes_cli = types.ModuleType('hermes_cli') + fake_hermes_cli.__path__ = [] + fake_hermes_cli.runtime_provider = fake_runtime_provider + monkeypatch.setitem(sys.modules, 'hermes_cli', fake_hermes_cli) + monkeypatch.setitem(sys.modules, 'hermes_cli.runtime_provider', fake_runtime_provider) + + class FakeAuxClient: + class chat: + class completions: + @staticmethod + def create(model, messages): + captured['aux_create'] = {'model': model, 'messages': messages} + return types.SimpleNamespace( + choices=[ + types.SimpleNamespace( + message=types.SimpleNamespace( + content='Notice: Profile-routed update summaries work.' + ) + ) + ] + ) + + def fake_get_text_auxiliary_client(task, main_runtime=None): + captured['aux_env'] = { + 'HERMES_HOME': os.environ.get('HERMES_HOME'), + 'HERMES_TEST_PROFILE_ENV': os.environ.get('HERMES_TEST_PROFILE_ENV'), + 'SKILL_MODULE_HOME': getattr(fake_skill_module, 'HERMES_HOME'), + 'SKILL_MODULE_DIR': getattr(fake_skill_module, 'SKILLS_DIR'), + } + captured['aux_task'] = task + captured['main_runtime'] = dict(main_runtime or {}) + return FakeAuxClient(), 'profile-compression-model' + + fake_auxiliary_client = types.ModuleType('agent.auxiliary_client') + fake_auxiliary_client.get_text_auxiliary_client = fake_get_text_auxiliary_client + fake_agent = types.ModuleType('agent') + fake_agent.__path__ = [] + fake_agent.auxiliary_client = fake_auxiliary_client + monkeypatch.setitem(sys.modules, 'agent', fake_agent) + monkeypatch.setitem(sys.modules, 'agent.auxiliary_client', fake_auxiliary_client) + + with updates._cache_lock: + updates._summary_cache.clear() + + monkeypatch.setenv('HERMES_HOME', 'default-home') + monkeypatch.setenv('HERMES_TEST_PROFILE_ENV', 'default-runtime') + + body = { + 'target': 'webui', + 'updates': { + 'webui': { + 'behind': 1, + 'current_sha': 'profile-env-before', + 'latest_sha': f'profile-env-after-{time.time_ns()}', + 'compare_url': 'https://example.test/compare', + }, + }, + } + handler = FakeHandler(body) + + profiles.set_request_profile('work') + try: + routes.handle_post(handler, types.SimpleNamespace(path='/api/updates/summary')) + finally: + profiles.clear_request_profile() + + assert handler.status == 200 + payload = handler.response_payload() + assert payload['generated_by'] == 'llm' + assert captured['aux_task'] == 'compression' + assert captured['model_resolution_env'] == { + 'HERMES_HOME': str(profile_home), + 'HERMES_TEST_PROFILE_ENV': 'work-runtime', + } + assert captured['aux_env'] == { + 'HERMES_HOME': str(profile_home), + 'HERMES_TEST_PROFILE_ENV': 'work-runtime', + 'SKILL_MODULE_HOME': profile_home, + 'SKILL_MODULE_DIR': profile_home / 'skills', + } + assert captured['aux_create']['model'] == 'profile-compression-model' + assert getattr(fake_skill_module, 'HERMES_HOME') == 'default-home' + assert getattr(fake_skill_module, 'SKILLS_DIR') == 'default-home/skills' + assert os.environ.get('HERMES_HOME') == 'default-home' + assert os.environ.get('HERMES_TEST_PROFILE_ENV') == 'default-runtime' + class TestUiJsUpdateBanner: """#813 + #814 — UI must show persistent error, force button, and correct toast.""" From f38c70415f76762d386663df0ee66fa1910cc75a Mon Sep 17 00:00:00 2001 From: starship-s <45587122+starship-s@users.noreply.github.com> Date: Fri, 15 May 2026 03:09:16 -0600 Subject: [PATCH 03/13] test(profiles): allow reentrant env lock --- tests/test_sprint29.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/test_sprint29.py b/tests/test_sprint29.py index 351bac5e..4050666e 100644 --- a/tests/test_sprint29.py +++ b/tests/test_sprint29.py @@ -712,11 +712,18 @@ class TestSSRFCheck: class TestENVLock: def test_env_lock_importable_from_streaming(self): - """_ENV_LOCK must be importable from api.streaming.""" + """_ENV_LOCK must be an importable threading-style reentrant lock.""" from api.streaming import _ENV_LOCK - import threading - assert isinstance(_ENV_LOCK, type(threading.Lock())), \ - "_ENV_LOCK must be a threading.Lock" + + assert hasattr(_ENV_LOCK, "acquire"), "_ENV_LOCK must expose acquire()" + assert hasattr(_ENV_LOCK, "release"), "_ENV_LOCK must expose release()" + assert hasattr(_ENV_LOCK, "__enter__"), "_ENV_LOCK must support context manager use" + assert hasattr(_ENV_LOCK, "__exit__"), "_ENV_LOCK must support context manager use" + + with _ENV_LOCK: + acquired = _ENV_LOCK.acquire(False) + assert acquired, "_ENV_LOCK must allow reentrant acquisition" + _ENV_LOCK.release() def test_env_lock_importable_in_routes(self): """api.routes must be able to import _ENV_LOCK from api.streaming.""" From 4ffecdd7c90bc28e685ec94fa3bb737f3cbecdab Mon Sep 17 00:00:00 2001 From: starship-s <45587122+starship-s@users.noreply.github.com> Date: Fri, 15 May 2026 03:58:40 -0600 Subject: [PATCH 04/13] refactor(profiles): consolidate background profile env --- api/profiles.py | 64 +++++++++ api/routes.py | 219 +++++++++++------------------- api/streaming.py | 53 +------- tests/test_title_aux_routing.py | 24 ++++ tests/test_update_banner_fixes.py | 2 - 5 files changed, 172 insertions(+), 190 deletions(-) diff --git a/api/profiles.py b/api/profiles.py index 577c9c3a..8f70275f 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -15,6 +15,7 @@ import re import shutil import sys import threading +from contextlib import contextmanager from pathlib import Path from typing import Optional @@ -674,6 +675,69 @@ def get_profile_runtime_env(home: Path) -> dict[str, str]: return env +@contextmanager +def profile_env_for_background_worker( + session, + purpose: str = "background worker", + logger_override: Optional[logging.Logger] = None, +): + """Temporarily route detached worker config reads through a profile. + + Background WebUI workers run outside the request/streaming thread that + established the profile-scoped environment. Workers that read agent config, + runtime provider settings, or skill paths must temporarily apply the + session/request profile env or they can fall back to the server-default + profile. Pass either a session-like object with `.profile` or a profile name. + """ + log = logger_override or logger + raw_profile = session if isinstance(session, str) else getattr(session, "profile", "") + profile = str(raw_profile or "").strip() + if not profile or profile == "default": + yield + return + + try: + # Lazy import avoids a module-load cycle: streaming imports this helper. + from api.streaming import _ENV_LOCK + + profile_home_path = Path(get_hermes_home_for_profile(profile)) + runtime_env = get_profile_runtime_env(profile_home_path) + except Exception: + log.debug( + "Failed to resolve profile env for %s profile %s; falling back to current env", + purpose, + profile, + exc_info=True, + ) + yield + return + + env_keys = set(runtime_env.keys()) | {"HERMES_HOME"} + with _ENV_LOCK: + old_env = {key: os.environ.get(key) for key in env_keys} + skill_home_snapshot = snapshot_skill_home_modules() + try: + os.environ.update(runtime_env) + os.environ["HERMES_HOME"] = str(profile_home_path) + try: + patch_skill_home_modules(profile_home_path) + except Exception: + log.debug( + "Failed to patch skill modules for %s profile %s", + purpose, + profile, + exc_info=True, + ) + yield + finally: + for key, old_value in old_env.items(): + if old_value is None: + os.environ.pop(key, None) + else: + os.environ[key] = old_value + restore_skill_home_modules(skill_home_snapshot) + + def _set_hermes_home(home: Path): """Set HERMES_HOME env var and monkey-patch cached module-level paths.""" os.environ['HERMES_HOME'] = str(home) diff --git a/api/routes.py b/api/routes.py index 4bed255a..7a78791a 100644 --- a/api/routes.py +++ b/api/routes.py @@ -20,9 +20,8 @@ import threading import time import uuid import re -from types import SimpleNamespace from pathlib import Path -from contextlib import closing, contextmanager +from contextlib import closing from urllib.parse import parse_qs from api.agent_sessions import ( MESSAGING_SOURCES, @@ -31,6 +30,7 @@ from api.agent_sessions import ( read_session_lineage_report, ) from api.compression_anchor import visible_messages_for_anchor +from api.profiles import get_active_profile_name as _get_active_profile_name, profile_env_for_background_worker logger = logging.getLogger(__name__) @@ -72,57 +72,6 @@ _CSP_REPORT_RATE_LIMIT_MAX = 100 _CSP_REPORT_MAX_BODY_BYTES = 64 * 1024 -@contextmanager -def _profile_env_for_background_worker(session, purpose: str = "background worker"): - """Temporarily route agent/config reads through a session's profile. - - Detached WebUI workers run in their own threads, so they do not inherit the - streaming thread's profile-scoped HERMES_HOME/runtime environment. Any - worker that calls hermes-agent config/runtime helpers must set the session - profile explicitly or it may read the default profile instead. - """ - profile = str(getattr(session, "profile", "") or "").strip() - if not profile or profile == "default": - yield - return - - try: - from api.profiles import ( - get_hermes_home_for_profile, - get_profile_runtime_env, - patch_skill_home_modules, - restore_skill_home_modules, - snapshot_skill_home_modules, - ) - from api.streaming import _ENV_LOCK - - profile_home_path = Path(get_hermes_home_for_profile(profile)) - runtime_env = get_profile_runtime_env(profile_home_path) - except Exception: - yield - return - - env_keys = set(runtime_env.keys()) | {"HERMES_HOME"} - with _ENV_LOCK: - old_env = {key: os.environ.get(key) for key in env_keys} - skill_home_snapshot = snapshot_skill_home_modules() - try: - os.environ.update(runtime_env) - os.environ["HERMES_HOME"] = str(profile_home_path) - try: - patch_skill_home_modules(profile_home_path) - except Exception: - logger.debug("Failed to patch skill modules for %s profile %s", purpose, profile) - yield - finally: - for key, old_value in old_env.items(): - if old_value is None: - os.environ.pop(key, None) - else: - os.environ[key] = old_value - restore_skill_home_modules(skill_home_snapshot) - - # ── Profile-scoped session/project filtering (#1611, #1614) ──────────────── # # Sessions and projects are stored in the WebUI sidecar without per-row @@ -5495,100 +5444,94 @@ def handle_post(handler, parsed) -> bool: target = body.get("target") if isinstance(body, dict) else None def _llm_update_summary(system_prompt: str, user_prompt: str) -> str: - try: - from api.profiles import get_active_profile_name - active_profile = get_active_profile_name() or "default" - except Exception: - active_profile = "default" + active_profile = _get_active_profile_name() or "default" - with _profile_env_for_background_worker( - SimpleNamespace(profile=active_profile), + with profile_env_for_background_worker( + active_profile, "update summary", + logger_override=logger, ): - return _llm_update_summary_with_profile_env(system_prompt, user_prompt) - - def _llm_update_summary_with_profile_env(system_prompt: str, user_prompt: str) -> str: - from api.config import ( - get_effective_default_model, - resolve_model_provider, - resolve_custom_provider_connection, - ) - - messages = [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt}, - ] - - _main_model, _main_provider, _main_base_url = resolve_model_provider(get_effective_default_model()) - _main_api_key = None - try: - from api.oauth import resolve_runtime_provider_with_anthropic_env_lock - from hermes_cli.runtime_provider import resolve_runtime_provider - - _rt = resolve_runtime_provider_with_anthropic_env_lock( - resolve_runtime_provider, - requested=_main_provider, + from api.config import ( + get_effective_default_model, + resolve_model_provider, + resolve_custom_provider_connection, ) - _main_api_key = _rt.get("api_key") - if not _main_provider: - _main_provider = _rt.get("provider") - if not _main_base_url: - _main_base_url = _rt.get("base_url") - except Exception as _e: - logger.debug("update summary runtime provider resolution failed: %s", _e) - if isinstance(_main_provider, str) and _main_provider.startswith("custom:"): - _cp_key, _cp_base = resolve_custom_provider_connection(_main_provider) - if not _main_api_key and _cp_key: - _main_api_key = _cp_key - if not _main_base_url and _cp_base: - _main_base_url = _cp_base - main_runtime = { - "provider": _main_provider, - "model": _main_model, - "base_url": _main_base_url, - "api_key": _main_api_key, - } + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ] - try: - from agent.auxiliary_client import get_text_auxiliary_client + _main_model, _main_provider, _main_base_url = resolve_model_provider(get_effective_default_model()) + _main_api_key = None + try: + from api.oauth import resolve_runtime_provider_with_anthropic_env_lock + from hermes_cli.runtime_provider import resolve_runtime_provider - # Update summaries are a short text-compression/summarization task. - # Reuse the documented auxiliary.compression slot instead of - # inventing a WebUI-only auxiliary task name that users cannot - # discover in the Hermes Agent setup/config UI. - aux_client, aux_model = get_text_auxiliary_client( - "compression", - main_runtime=main_runtime, - ) - if aux_client is not None and aux_model: - response = aux_client.chat.completions.create( - model=aux_model, - messages=messages, + _rt = resolve_runtime_provider_with_anthropic_env_lock( + resolve_runtime_provider, + requested=_main_provider, ) - return str(response.choices[0].message.content or "").strip() - except Exception as _e: - logger.debug("update summary auxiliary model failed; falling back to main model: %s", _e) + _main_api_key = _rt.get("api_key") + if not _main_provider: + _main_provider = _rt.get("provider") + if not _main_base_url: + _main_base_url = _rt.get("base_url") + except Exception as _e: + logger.debug("update summary runtime provider resolution failed: %s", _e) + if isinstance(_main_provider, str) and _main_provider.startswith("custom:"): + _cp_key, _cp_base = resolve_custom_provider_connection(_main_provider) + if not _main_api_key and _cp_key: + _main_api_key = _cp_key + if not _main_base_url and _cp_base: + _main_base_url = _cp_base - from run_agent import AIAgent + main_runtime = { + "provider": _main_provider, + "model": _main_model, + "base_url": _main_base_url, + "api_key": _main_api_key, + } - agent = AIAgent( - model=_main_model, - provider=_main_provider, - base_url=_main_base_url, - api_key=_main_api_key, - platform="webui", - quiet_mode=True, - enabled_toolsets=[], - session_id=f"updates-summary-{uuid.uuid4().hex[:8]}", - ) - result = agent.run_conversation( - user_message=user_prompt, - system_message=system_prompt, - conversation_history=[], - task_id=f"updates-summary-{uuid.uuid4().hex[:8]}", - ) - return str(result.get("final_response") or "").strip() + try: + from agent.auxiliary_client import get_text_auxiliary_client + + # Update summaries are a short text-compression/summarization task. + # Reuse the documented auxiliary.compression slot instead of + # inventing a WebUI-only auxiliary task name that users cannot + # discover in the Hermes Agent setup/config UI. + aux_client, aux_model = get_text_auxiliary_client( + "compression", + main_runtime=main_runtime, + ) + if aux_client is not None and aux_model: + response = aux_client.chat.completions.create( + model=aux_model, + messages=messages, + ) + return str(response.choices[0].message.content or "").strip() + except Exception as _e: + logger.debug("update summary auxiliary model failed; falling back to main model: %s", _e) + + from run_agent import AIAgent + + agent = AIAgent( + model=_main_model, + provider=_main_provider, + base_url=_main_base_url, + api_key=_main_api_key, + platform="webui", + quiet_mode=True, + enabled_toolsets=[], + session_id=f"updates-summary-{uuid.uuid4().hex[:8]}", + ) + result = agent.run_conversation( + user_message=user_prompt, + system_message=system_prompt, + conversation_history=[], + task_id=f"updates-summary-{uuid.uuid4().hex[:8]}", + ) + return str(result.get("final_response") or "").strip() return j(handler, summarize_update_payload(updates, llm_callback=_llm_update_summary, target=target)) @@ -8338,7 +8281,7 @@ def _run_manual_compression_job(sid, body): except KeyError: session = None if session is not None: - with _profile_env_for_background_worker(session, "manual compression"): + with profile_env_for_background_worker(session, "manual compression", logger_override=logger): _handle_session_compress(memory_handler, body) else: _handle_session_compress(memory_handler, body) diff --git a/api/streaming.py b/api/streaming.py index fc117de5..6bf150c6 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -34,6 +34,7 @@ from api.config import ( ) from api.helpers import redact_session_data, _redact_text from api.compression_anchor import visible_messages_for_anchor +from api.profiles import profile_env_for_background_worker from api.metering import meter from api.turn_journal import append_turn_journal_event_for_stream @@ -1445,54 +1446,6 @@ def _is_generic_fallback_title(title: str) -> bool: return str(title or '').strip().lower() in {'conversation topic'} -@contextlib.contextmanager -def _profile_env_for_title_worker(session): - """Temporarily route auxiliary title-generation config through the session profile. - - Background title workers run in their own thread and do not inherit the - streaming thread's thread-local/profile context. Without setting - HERMES_HOME here, hermes-agent's auxiliary_client.load_config() can read - the default profile and use the wrong title_generation model. - """ - profile = str(getattr(session, 'profile', '') or '').strip() - if not profile or profile == 'default': - yield - return - try: - from api.profiles import ( - get_hermes_home_for_profile, - get_profile_runtime_env, - patch_skill_home_modules, - restore_skill_home_modules, - snapshot_skill_home_modules, - ) - profile_home_path = Path(get_hermes_home_for_profile(profile)) - runtime_env = get_profile_runtime_env(profile_home_path) - except Exception: - yield - return - - env_keys = set(runtime_env.keys()) | {'HERMES_HOME'} - with _ENV_LOCK: - old_env = {key: os.environ.get(key) for key in env_keys} - skill_home_snapshot = snapshot_skill_home_modules() - try: - os.environ.update(runtime_env) - os.environ['HERMES_HOME'] = str(profile_home_path) - try: - patch_skill_home_modules(profile_home_path) - except Exception: - logger.debug("Failed to patch skill modules for background title profile %s", profile) - yield - finally: - for key, old_value in old_env.items(): - if old_value is None: - os.environ.pop(key, None) - else: - os.environ[key] = old_value - restore_skill_home_modules(skill_home_snapshot) - - def _run_background_title_update(session_id: str, user_text: str, assistant_text: str, placeholder_title: str, put_event, agent=None): """Generate and publish a better title after `done`, then end the stream.""" try: @@ -1516,7 +1469,7 @@ def _run_background_title_update(session_id: str, user_text: str, assistant_text if not still_auto: _put_title_status(put_event, session_id, 'skipped', 'manual_title', current) return - with _profile_env_for_title_worker(s): + with profile_env_for_background_worker(s, "background title", logger_override=logger): aux_title_configured = _aux_title_configured() if agent and not aux_title_configured: next_title, llm_status, raw_preview = _generate_llm_session_title_for_agent(agent, user_text, assistant_text) @@ -1597,7 +1550,7 @@ def _run_background_title_refresh(session_id: str, user_text: str, assistant_tex return if not effective or effective in ('Untitled', 'New Chat'): return - with _profile_env_for_title_worker(s): + with profile_env_for_background_worker(s, "background title", logger_override=logger): aux_title_configured = _aux_title_configured() if agent and not aux_title_configured: next_title, llm_status, raw_preview = _generate_llm_session_title_for_agent(agent, user_text, assistant_text) diff --git a/tests/test_title_aux_routing.py b/tests/test_title_aux_routing.py index c1c90a43..8ffbf9ef 100644 --- a/tests/test_title_aux_routing.py +++ b/tests/test_title_aux_routing.py @@ -380,6 +380,30 @@ class TestReasoningModelTitleGeneration(unittest.TestCase): class TestBackgroundTitleProfileRouting(unittest.TestCase): + def test_profile_env_context_logs_fail_open_resolution_errors(self): + """Profile env setup failures should be diagnosable without breaking workers.""" + import api.profiles as profiles + + session = types.SimpleNamespace(profile='work') + captured = {} + + with patch( + 'api.profiles.get_hermes_home_for_profile', + side_effect=RuntimeError('profile lookup failed'), + ): + with patch.dict(os.environ, {'HERMES_HOME': 'default-home'}, clear=False): + with self.assertLogs('api.profiles', level='DEBUG') as logs: + with profiles.profile_env_for_background_worker(session, 'background title'): + captured['HERMES_HOME'] = os.environ.get('HERMES_HOME') + + message_found = any( + 'Failed to resolve profile env for background title profile work' in record.getMessage() + for record in logs.records + ) + self.assertEqual(captured['HERMES_HOME'], 'default-home') + self.assertTrue(message_found) + self.assertTrue(any(record.exc_info for record in logs.records)) + def test_skill_home_snapshot_removes_modules_imported_during_context(self): """Modules first imported inside a temporary profile context must not leak.""" import api.profiles as profiles diff --git a/tests/test_update_banner_fixes.py b/tests/test_update_banner_fixes.py index 23b54b6b..345bd9e6 100644 --- a/tests/test_update_banner_fixes.py +++ b/tests/test_update_banner_fixes.py @@ -431,8 +431,6 @@ class TestUpdateSummaryRouteModelSelection: assert '"compression"' in src assert '"update_summary"' not in src assert 'main_runtime=main_runtime' in src - assert '_profile_env_for_background_worker' in src - assert 'get_active_profile_name' in src assert 'update summary auxiliary model failed; falling back to main model' in src assert 'from run_agent import AIAgent' in src From abb60573046749914bd67f19e7389a146935b07a Mon Sep 17 00:00:00 2001 From: starship-s <45587122+starship-s@users.noreply.github.com> Date: Fri, 15 May 2026 04:14:09 -0600 Subject: [PATCH 05/13] test(profiles): keep profile module reloads isolated --- api/routes.py | 11 +++++++---- api/streaming.py | 9 ++++++--- tests/test_profile_path_security.py | 7 ++++++- tests/test_title_aux_routing.py | 5 +++-- 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/api/routes.py b/api/routes.py index 7a78791a..20de77c5 100644 --- a/api/routes.py +++ b/api/routes.py @@ -30,7 +30,6 @@ from api.agent_sessions import ( read_session_lineage_report, ) from api.compression_anchor import visible_messages_for_anchor -from api.profiles import get_active_profile_name as _get_active_profile_name, profile_env_for_background_worker logger = logging.getLogger(__name__) @@ -5444,9 +5443,11 @@ def handle_post(handler, parsed) -> bool: target = body.get("target") if isinstance(body, dict) else None def _llm_update_summary(system_prompt: str, user_prompt: str) -> str: - active_profile = _get_active_profile_name() or "default" + from api import profiles as profiles_api - with profile_env_for_background_worker( + active_profile = profiles_api.get_active_profile_name() or "default" + + with profiles_api.profile_env_for_background_worker( active_profile, "update summary", logger_override=logger, @@ -8281,7 +8282,9 @@ def _run_manual_compression_job(sid, body): except KeyError: session = None if session is not None: - with profile_env_for_background_worker(session, "manual compression", logger_override=logger): + from api import profiles as profiles_api + + with profiles_api.profile_env_for_background_worker(session, "manual compression", logger_override=logger): _handle_session_compress(memory_handler, body) else: _handle_session_compress(memory_handler, body) diff --git a/api/streaming.py b/api/streaming.py index 6bf150c6..b0fc110b 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -34,7 +34,6 @@ from api.config import ( ) from api.helpers import redact_session_data, _redact_text from api.compression_anchor import visible_messages_for_anchor -from api.profiles import profile_env_for_background_worker from api.metering import meter from api.turn_journal import append_turn_journal_event_for_stream @@ -1469,7 +1468,9 @@ def _run_background_title_update(session_id: str, user_text: str, assistant_text if not still_auto: _put_title_status(put_event, session_id, 'skipped', 'manual_title', current) return - with profile_env_for_background_worker(s, "background title", logger_override=logger): + from api import profiles as profiles_api + + with profiles_api.profile_env_for_background_worker(s, "background title", logger_override=logger): aux_title_configured = _aux_title_configured() if agent and not aux_title_configured: next_title, llm_status, raw_preview = _generate_llm_session_title_for_agent(agent, user_text, assistant_text) @@ -1550,7 +1551,9 @@ def _run_background_title_refresh(session_id: str, user_text: str, assistant_tex return if not effective or effective in ('Untitled', 'New Chat'): return - with profile_env_for_background_worker(s, "background title", logger_override=logger): + from api import profiles as profiles_api + + with profiles_api.profile_env_for_background_worker(s, "background title", logger_override=logger): aux_title_configured = _aux_title_configured() if agent and not aux_title_configured: next_title, llm_status, raw_preview = _generate_llm_session_title_for_agent(agent, user_text, assistant_text) diff --git a/tests/test_profile_path_security.py b/tests/test_profile_path_security.py index 097a3231..11e7eb37 100644 --- a/tests/test_profile_path_security.py +++ b/tests/test_profile_path_security.py @@ -27,8 +27,13 @@ def _reload_profiles_module(base_home: Path): profiles = importlib.import_module("api.profiles") - # Restore original modules so the cache stays consistent for the rest of the suite. + # Restore original modules and package attributes so the cache stays + # consistent for the rest of the suite. sys.modules.update(_saved) + api_pkg = sys.modules.get("api") + if api_pkg is not None: + for name, module in _saved.items(): + setattr(api_pkg, name.rsplit(".", 1)[-1], module) return profiles diff --git a/tests/test_title_aux_routing.py b/tests/test_title_aux_routing.py index 8ffbf9ef..a125d361 100644 --- a/tests/test_title_aux_routing.py +++ b/tests/test_title_aux_routing.py @@ -387,8 +387,9 @@ class TestBackgroundTitleProfileRouting(unittest.TestCase): session = types.SimpleNamespace(profile='work') captured = {} - with patch( - 'api.profiles.get_hermes_home_for_profile', + with patch.object( + profiles, + 'get_hermes_home_for_profile', side_effect=RuntimeError('profile lookup failed'), ): with patch.dict(os.environ, {'HERMES_HOME': 'default-home'}, clear=False): From 0e9017a665f96ef4444ca0b587746745fc203b95 Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Fri, 15 May 2026 08:58:24 -0600 Subject: [PATCH 06/13] refine workspace panel header layout --- static/boot.js | 5 +--- static/index.html | 3 +-- static/style.css | 21 +++++++---------- tests/test_mobile_layout.py | 10 ++------ tests/test_sprint41.py | 16 +------------ tests/test_sprint44.py | 8 ------- tests/test_workspace_panel_session_list.py | 27 ++++++++++------------ 7 files changed, 25 insertions(+), 65 deletions(-) diff --git a/static/boot.js b/static/boot.js index e967f292..afd0f576 100644 --- a/static/boot.js +++ b/static/boot.js @@ -202,10 +202,7 @@ function syncWorkspacePanelUI(){ if(clearBtn){ clearBtn.disabled=!isOpen; _setButtonTooltip(clearBtn, hasPreview?'Close preview':'Hide workspace panel'); - // On desktop, only show the X button when a file preview is open. - // In browse mode the chevron (btnCollapseWorkspacePanel) already serves - // as the close control, so showing both produces a duplicate X. - if(!isCompact) clearBtn.style.display=hasPreview?'':'none'; + if(!isCompact) clearBtn.style.display=''; } } diff --git a/static/index.html b/static/index.html index c32974ce..669f33a5 100644 --- a/static/index.html +++ b/static/index.html @@ -1181,10 +1181,9 @@