Release v0.51.224 — Release GR (stage-p6 — profile tool/skill config authoritative on streaming worker #3294) (#3456)
* 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 <gottipx@users.noreply.github.com> * 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 <gottipx@users.noreply.github.com>
This commit is contained in:
parent
b850a88e8b
commit
bb5aa02523
4 changed files with 191 additions and 4 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
134
tests/test_issue3294_profile_toolset_scoping.py
Normal file
134
tests/test_issue3294_profile_toolset_scoping.py
Normal file
|
|
@ -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",
|
||||
]
|
||||
Loading…
Reference in a new issue