mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 03:00:23 +00:00
refactor(profiles): consolidate background profile env
This commit is contained in:
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user