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