refactor(profiles): consolidate background profile env

This commit is contained in:
starship-s
2026-05-15 03:58:40 -06:00
parent f38c70415f
commit 4ffecdd7c9
5 changed files with 172 additions and 190 deletions
+64
View File
@@ -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)
+81 -138
View File
@@ -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)
+3 -50
View File
@@ -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)
+24
View File
@@ -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
-2
View File
@@ -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