From bb5aa02523b335b0eb937c707576df95d30e265f Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 2 Jun 2026 18:33:22 -0700 Subject: [PATCH] =?UTF-8?q?Release=20v0.51.224=20=E2=80=94=20Release=20GR?= =?UTF-8?q?=20(stage-p6=20=E2=80=94=20profile=20tool/skill=20config=20auth?= =?UTF-8?q?oritative=20on=20streaming=20worker=20#3294)=20(#3456)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: respect profile toolset/skill config on WebUI streaming worker (#3294) The streaming agent runs on a detached worker thread that does not inherit the per-request thread-local profile context (set from the hermes_profile cookie on the HTTP handler thread). On that worker, the ambient get_config() resolves via get_active_profile_name() which falls back to the process-global _active_profile (usually 'default'). A session under a non-default profile with an empty platform_toolsets.cli therefore loaded the DEFAULT profile's full toolset list, inflating a tools-disabled profile's prompt from ~400 to ~15K input tokens. Add api.config.get_config_for_profile_home() — a race-free direct disk read of an explicit profile home's config.yaml (no shared-cache mutation), which defers to get_config() when the requested home matches the ambient path so in-memory test overrides are preserved. The streaming worker now resolves _cfg from the session's own profile home, fixing toolsets, prefill context, and fallback chains in one place. Closes #3294 Co-authored-by: gottipx * docs(changelog): v0.51.224 — #3294 profile toolset config (dropped #3405 to hold per Codex stale-model-repair finding) --------- Co-authored-by: nesquena-hermes <[email protected]> Co-authored-by: gottipx --- CHANGELOG.md | 7 +- api/config.py | 37 +++++ api/streaming.py | 17 ++- .../test_issue3294_profile_toolset_scoping.py | 134 ++++++++++++++++++ 4 files changed, 191 insertions(+), 4 deletions(-) create mode 100644 tests/test_issue3294_profile_toolset_scoping.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 48aa4643..e1acb687 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,12 @@ ## [Unreleased] -## [v0.51.223] — 2026-06-02 — Release GQ (stage-p5 — openai-api picker provider + MiniMax-M3) +## [v0.51.224] — 2026-06-03 — Release GR (stage-p6 — profile tool/skill config authoritative on the streaming worker) + +### Fixed +- Profile tool/skill restrictions are now respected for WebUI chats even when the per-session "Tool Restrictions" field is left blank. The streaming agent runs on a detached worker thread that does not inherit the per-request thread-local profile context, so the ambient `get_config()` resolved the process-global `default` profile and loaded its `platform_toolsets.cli` (all tools) instead of the session profile's configured list — inflating a tools-disabled profile's prompt from ~400 to ~15K input tokens. The worker now reads the session's own profile config explicitly via a new `get_config_for_profile_home()` helper (a race-free direct disk read with no shared-cache mutation), so toolsets, prefill context, and fallback chains all match the profile the session actually runs under (#3294, @nesquena-hermes). + +## [v0.51.223] — 2026-06-02 — Release GQ (stage-p5 — openai-api first-class picker provider + MiniMax-M3) ### Fixed - GPT models now appear in the model picker when hermes-agent exposes its built-in OpenAI provider under the `openai-api` slug (the one activated by `OPENAI_API_KEY` / `OPENAI_BASE_URL`, distinct from `openai-codex`). `openai-api` is now a first-class picker provider in `_PROVIDER_DISPLAY` / `_PROVIDER_MODELS` rather than an alias of `openai` — an alias would have fixed the display but broken the send path, since the agent registry has `openai-api` and not `openai`. Env detection for `OPENAI_API_KEY` was also corrected to surface `openai-api` instead of a bare `openai` the agent registry can't resolve (#3443, @rodboev). diff --git a/api/config.py b/api/config.py index b51e562b..85abefb0 100644 --- a/api/config.py +++ b/api/config.py @@ -382,6 +382,43 @@ def _load_yaml_config_file(config_path: Path) -> dict: return {} +def get_config_for_profile_home(profile_home: "Path | str | None") -> dict: + """Return the config dict for an explicit profile home directory. + + The streaming agent runs on a detached worker thread that does NOT inherit + the per-request thread-local profile context (set from the ``hermes_profile`` + cookie on the HTTP handler thread). On that worker, the ambient + ``get_config()`` resolves through ``get_active_profile_name()`` which falls + back to the process-global ``_active_profile`` (usually ``default``) — so a + session running under a non-default profile would silently read the + **default** profile's ``config.yaml`` for toolsets, prefill context, and + fallback chains (issue #3294). + + This helper reads the config for a *known* profile home directly off disk, + bypassing the thread-local resolver entirely. When ``profile_home`` matches + the path the ambient resolver would pick (the common single-profile case), + we return the cached ``get_config()`` to preserve in-memory overrides used + by tests and runtime callers. Only when the session's profile home diverges + from the ambient path do we read the session profile's file directly — a + pure read with no global cache mutation, so it is race-free across + concurrent sessions on different profiles. + """ + if not profile_home: + return get_config() + try: + target = Path(profile_home).expanduser() + except Exception: + return get_config() + # If the ambient resolver already points at this profile home, defer to + # get_config() so in-memory overrides (monkeypatched cfg) are honored. + try: + if _get_config_path().parent == target: + return get_config() + except Exception: + pass + return _load_yaml_config_file(target / "config.yaml") + + def _save_yaml_config_file(config_path: Path, config_data: dict) -> None: try: import yaml as _yaml diff --git a/api/streaming.py b/api/streaming.py index 777a94ba..8685a418 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -4892,9 +4892,20 @@ def _run_agent_streaming( resolved_provider, resolved_api_key, resolved_base_url ) - # Read per-profile config at call time (not module-level snapshot) - from api.config import get_config as _get_config - _cfg = _get_config() + # Read per-profile config at call time (not module-level snapshot). + # The streaming worker is a detached thread that does NOT inherit the + # per-request thread-local profile context, so the ambient + # get_config() would resolve the process-global (default) profile and + # leak the wrong profile's toolsets / prefill / fallback config into + # this run (issue #3294). Read the SESSION's own profile home + # explicitly so toolsets and context match the profile the session + # actually runs under. + from api.config import get_config_for_profile_home as _get_config_for_home + try: + _cfg = _get_config_for_home(_profile_home) + except Exception: + from api.config import get_config as _get_config + _cfg = _get_config() _prefill_context = _load_webui_prefill_context(_cfg) _prefill_messages = _prefill_messages_with_webui_context(_prefill_context, _cfg) put('context_status', { diff --git a/tests/test_issue3294_profile_toolset_scoping.py b/tests/test_issue3294_profile_toolset_scoping.py new file mode 100644 index 00000000..7799f53b --- /dev/null +++ b/tests/test_issue3294_profile_toolset_scoping.py @@ -0,0 +1,134 @@ +"""Regression coverage for #3294 — profile tool configuration not respected in WebUI. + +When a non-default profile leaves the per-session "Tool Restrictions" +(`enabled_toolsets`) blank, the streaming worker must resolve toolsets (and the +rest of the per-profile config: prefill context, fallback chains) from the +*session's own profile* config.yaml — not from the process-global ``default`` +profile. + +Root cause: the streaming agent runs on a detached ``threading.Thread`` that +does NOT inherit the per-request thread-local profile context (set from the +``hermes_profile`` cookie on the HTTP handler thread). On that worker the +ambient ``get_config()`` resolves through ``get_active_profile_name()`` which +falls back to the process-global ``_active_profile`` (usually ``default``). A +profile B with an empty ``platform_toolsets.cli`` would therefore load the +default profile's full toolset list, inflating the prompt from ~400 to ~15K +tokens (the reporter's symptom). + +Fix: ``api.config.get_config_for_profile_home(profile_home)`` reads the config +for an explicit profile home directly off disk, bypassing the thread-local +resolver. The streaming worker passes the session's own resolved profile home. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parent.parent +if str(REPO) not in sys.path: + sys.path.insert(0, str(REPO)) + + +def _write_cfg(home: Path, cli_toolsets) -> None: + home.mkdir(parents=True, exist_ok=True) + import yaml + + home.joinpath("config.yaml").write_text( + yaml.safe_dump({"platform_toolsets": {"cli": cli_toolsets}}, sort_keys=False), + encoding="utf-8", + ) + + +@pytest.fixture +def _two_profiles(tmp_path, monkeypatch): + """default profile = all tools; profile B = empty toolset list.""" + from api import config as cfg + + default_home = tmp_path / "default" + profile_b_home = tmp_path / "profiles" / "b" + _write_cfg(default_home, ["terminal", "file", "web", "search", "browser"]) + _write_cfg(profile_b_home, []) + + # Point the ambient resolver at the DEFAULT profile, simulating a worker + # thread that fell back to the process-global default profile. + monkeypatch.setenv("HERMES_CONFIG_PATH", str(default_home / "config.yaml")) + cfg.reload_config() + yield cfg, default_home, profile_b_home + + +def test_ambient_get_config_reads_default_profile(_two_profiles): + """Baseline: the ambient resolver sees the DEFAULT profile (the bug source).""" + cfg, default_home, profile_b_home = _two_profiles + ambient = cfg.get_config() + assert ambient.get("platform_toolsets", {}).get("cli") == [ + "terminal", + "file", + "web", + "search", + "browser", + ] + + +def test_get_config_for_profile_home_reads_session_profile(_two_profiles): + """The fix: explicitly resolving profile B's home reads B's empty toolsets, + even though the ambient resolver still points at the default profile.""" + cfg, default_home, profile_b_home = _two_profiles + + profile_b_cfg = cfg.get_config_for_profile_home(profile_b_home) + assert profile_b_cfg.get("platform_toolsets", {}).get("cli") == [] + + # And the toolset resolver run against profile B's config must yield a + # strictly smaller set than the default profile's — the token-inflation + # the reporter observed comes from resolving against the wrong (default) + # profile's full list. + default_toolsets = set(cfg._resolve_cli_toolsets(cfg.get_config())) + profile_b_toolsets = set(cfg._resolve_cli_toolsets(profile_b_cfg)) + assert profile_b_toolsets < default_toolsets + assert "terminal" in default_toolsets + assert "terminal" not in profile_b_toolsets + assert "browser" not in profile_b_toolsets + + +def test_matching_home_defers_to_get_config(_two_profiles): + """When the requested home matches the ambient path, defer to get_config() + so in-memory overrides (monkeypatched cfg / runtime overrides) are honored.""" + cfg, default_home, profile_b_home = _two_profiles + same = cfg.get_config_for_profile_home(default_home) + # Same dict identity path as get_config() (cached) — verify it returns the + # default profile's config, not a fresh disk read that could miss overrides. + assert same.get("platform_toolsets", {}).get("cli") == [ + "terminal", + "file", + "web", + "search", + "browser", + ] + + +def test_empty_or_none_home_falls_back_to_get_config(_two_profiles): + """A missing/empty profile home (ImportError fallback path in the worker) + must not crash — it falls back to the ambient get_config().""" + cfg, default_home, profile_b_home = _two_profiles + assert cfg.get_config_for_profile_home(None) == cfg.get_config() + assert cfg.get_config_for_profile_home("") == cfg.get_config() + + +def test_nonexistent_profile_home_returns_empty_not_default(_two_profiles): + """A profile home that diverges from ambient but has no config.yaml yet + returns an empty dict (the profile's own absent config) rather than silently + inheriting the default profile's tools.""" + cfg, default_home, profile_b_home = _two_profiles + ghost = profile_b_home.parent / "ghost" + result = cfg.get_config_for_profile_home(ghost) + assert result == {} + # Critically: it does NOT return the default profile's full toolset list. + assert result.get("platform_toolsets", {}).get("cli") != [ + "terminal", + "file", + "web", + "search", + "browser", + ]