fix(tools): isolate model tools by multiplex profile

This commit is contained in:
tachyon-r 2026-07-29 05:36:49 -04:00 committed by Teknium
parent 153442dd5b
commit 76cf19fee1
14 changed files with 448 additions and 36 deletions

View File

@ -10639,8 +10639,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
logger.warning("No adapter available for %s", _pval)
continue
# Set up message + fatal error handlers
adapter.set_message_handler(self._handle_message)
# Set up message + fatal error handlers. Under multiplexing the
# default profile needs the same whole-handler runtime scope as a
# secondary profile: authorization and prompt rendering both run
# before the narrower agent-turn scope is installed.
adapter.set_message_handler(self._primary_message_handler())
adapter.set_fatal_error_handler(self._handle_adapter_fatal_error)
adapter.set_session_store(self.session_store)
adapter.set_busy_session_handler(self._handle_active_session_busy_message)
@ -11741,7 +11744,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
del self._failed_platforms[platform]
continue
adapter.set_message_handler(self._handle_message)
adapter.set_message_handler(self._primary_message_handler())
adapter.set_fatal_error_handler(self._handle_adapter_fatal_error)
adapter.set_session_store(self.session_store)
adapter.set_busy_session_handler(self._handle_active_session_busy_message)
@ -12891,6 +12894,22 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
return _handler
def _make_default_profile_message_handler(self):
"""Scope a multiplexed default-profile message from ingress onward."""
profile_home = Path(get_hermes_home())
async def _handler(event):
with _profile_runtime_scope(profile_home):
return await self._handle_message(event)
return _handler
def _primary_message_handler(self):
"""Return the correctly scoped handler for a primary adapter."""
if getattr(self.config, "multiplex_profiles", False):
return self._make_default_profile_message_handler()
return self._handle_message
@staticmethod
def _adapter_credential_claim(
platform: Platform, adapter: Any

View File

@ -416,11 +416,13 @@ def _discord_tools_loaded() -> bool:
Returns False (safe default keeps the stale-API disclaimer) on any
error so a bad config can't silently promise tools the agent lacks.
"""
if not (os.environ.get("DISCORD_BOT_TOKEN") or "").strip():
return False
try:
from agent.secret_scope import get_secret
from hermes_cli.config import load_config
from hermes_cli.tools_config import _get_platform_tools
if not (get_secret("DISCORD_BOT_TOKEN", "") or "").strip():
return False
cfg = load_config()
enabled = _get_platform_tools(cfg, "discord", include_default_mcp_servers=False)
return "discord" in enabled or "discord_admin" in enabled

View File

@ -191,6 +191,16 @@ def _xai_credentials_present() -> bool:
pass
return bool(str(os.environ.get("XAI_API_KEY") or "").strip())
def _homeassistant_credentials_present() -> bool:
"""Return whether the active profile has a Home Assistant token."""
try:
from agent.secret_scope import get_secret
return bool((get_secret("HASS_TOKEN", "") or "").strip())
except Exception:
return False
# Platform-scoped toolsets: only appear in the `hermes tools` checklist for
# these platforms, and only resolve/save for these platforms. A toolset
# absent from this map is available on every platform (current behaviour).
@ -2282,7 +2292,7 @@ def _get_platform_tools(
default_off = set(_DEFAULT_OFF_TOOLSETS)
if platform in default_off and platform not in _TOOLSET_PLATFORM_RESTRICTIONS:
default_off.remove(platform)
if "homeassistant" in default_off and os.getenv("HASS_TOKEN"):
if "homeassistant" in default_off and _homeassistant_credentials_present():
default_off.remove("homeassistant")
_exempt_explicit_platform_native(
default_off, platform, explicitly_configured=explicitly_configured
@ -2344,7 +2354,7 @@ def _get_platform_tools(
# (e.g. cron) that run through _get_platform_tools without an
# explicit saved toolset list. Without this, Norbert's HA cron jobs
# regressed after #14798 made cron honor per-platform tool config.
if "homeassistant" in default_off and os.getenv("HASS_TOKEN"):
if "homeassistant" in default_off and _homeassistant_credentials_present():
default_off.remove("homeassistant")
# Symmetric carve-out for x_search auto-enable (see the inject
# block above). Without this, the default_off subtraction would

View File

@ -29,7 +29,13 @@ import threading
import time
from typing import Dict, Any, List, Optional, Tuple
from tools.registry import discover_builtin_tools, registry, tool_error
from tools.registry import (
CHECK_FN_CACHE_BYPASS,
check_fn_cache_scope,
discover_builtin_tools,
registry,
tool_error,
)
from toolsets import resolve_toolset, validate_toolset
logger = logging.getLogger(__name__)
@ -317,6 +323,7 @@ def get_tool_definitions(
# user-visible config edits that affect dynamic schemas (execute_code
# mode, discord action allowlist, etc.) without needing an explicit
# invalidate hook on every config-writer.
cache_key = None
if quiet_mode:
try:
from hermes_cli.config import get_config_path
@ -325,16 +332,19 @@ def get_tool_definitions(
cfg_fp = (cfg_stat.st_mtime_ns, cfg_stat.st_size)
except (FileNotFoundError, OSError, ImportError):
cfg_fp = None
cache_key = (
frozenset(enabled_toolsets) if enabled_toolsets is not None else None,
frozenset(disabled_toolsets) if disabled_toolsets else None,
registry._generation,
cfg_fp,
bool(os.environ.get("HERMES_KANBAN_TASK")),
bool(skip_tool_search_assembly),
_is_delegated_child_context(),
)
cached = _tool_defs_cache.get(cache_key)
profile_scope = check_fn_cache_scope()
if profile_scope != CHECK_FN_CACHE_BYPASS:
cache_key = (
frozenset(enabled_toolsets) if enabled_toolsets is not None else None,
frozenset(disabled_toolsets) if disabled_toolsets else None,
registry._generation,
cfg_fp,
bool(os.environ.get("HERMES_KANBAN_TASK")),
bool(skip_tool_search_assembly),
_is_delegated_child_context(),
profile_scope,
)
cached = _tool_defs_cache.get(cache_key) if cache_key is not None else None
if cached is not None:
# Update _last_resolved_tool_names so downstream callers see
# consistent state even on a cache hit.
@ -346,7 +356,7 @@ def get_tool_definitions(
result = _compute_tool_definitions(enabled_toolsets, disabled_toolsets, quiet_mode,
skip_tool_search_assembly=skip_tool_search_assembly)
if quiet_mode:
if quiet_mode and cache_key is not None:
# Cache the freshly-computed list, but hand callers a shallow copy so
# downstream mutations (e.g. run_agent appending memory/LCM tool
# schemas to self.tools) don't poison the cache. Without this, a
@ -361,6 +371,8 @@ def get_tool_definitions(
_tool_defs_cache.pop(next(iter(_tool_defs_cache))) # evict oldest
_tool_defs_cache[cache_key] = result
return list(result)
if quiet_mode:
return list(result)
return result

View File

@ -119,6 +119,43 @@ class TestPrimaryStartupSkipsEmptyTokenUnderMultiplex:
assert created == []
class TestPrimaryMessageRuntimeScope:
@pytest.mark.asyncio
async def test_default_profile_prompt_gate_sees_its_scoped_token(
self, tmp_path, monkeypatch
):
from agent import secret_scope
from gateway import run as run_mod
from gateway.run import GatewayRunner
home = tmp_path / "home"
home.mkdir()
(home / ".env").write_text(
"DISCORD_BOT_TOKEN=default-profile-token\n", encoding="utf-8"
)
(home / "config.yaml").write_text(
"platform_toolsets:\n discord:\n - discord\n", encoding="utf-8"
)
monkeypatch.setattr(run_mod, "get_hermes_home", lambda: home)
monkeypatch.setenv("DISCORD_BOT_TOKEN", "wrong-process-token")
secret_scope.set_multiplex_active(True)
runner = GatewayRunner.__new__(GatewayRunner)
runner.config = GatewayConfig(multiplex_profiles=True)
async def _handle_message(_event):
from gateway.session import _discord_tools_loaded
return _discord_tools_loaded()
runner._handle_message = _handle_message # type: ignore[method-assign]
handler = runner._primary_message_handler()
assert await handler(SimpleNamespace(source=SimpleNamespace(profile=None))) is True
with pytest.raises(secret_scope.UnscopedSecretError):
secret_scope.get_secret("DISCORD_BOT_TOKEN")
class TestReconnectDropsEmptyToken:
@pytest.mark.asyncio
async def test_empty_token_removed_from_queue(self):

View File

@ -100,6 +100,20 @@ def test_get_platform_tools_homeassistant_toolset_enabled_for_cron_when_hass_tok
assert "homeassistant" in cli_enabled
def test_get_platform_tools_homeassistant_uses_active_profile_token(monkeypatch):
from agent import secret_scope
monkeypatch.delenv("HASS_TOKEN", raising=False)
secret_scope.set_multiplex_active(True)
token = secret_scope.set_secret_scope({"HASS_TOKEN": "profile-token"})
try:
assert "homeassistant" in _get_platform_tools({}, "cron")
assert "homeassistant" in _get_platform_tools({}, "cli")
finally:
secret_scope.reset_secret_scope(token)
secret_scope.set_multiplex_active(False)
# ─── #35527: platform-restricted default-off toolsets (discord/discord_admin)
# are stripped by _DEFAULT_OFF_TOOLSETS even when the user explicitly opts in
# via the platform's native composite. The composite ``hermes-discord``

View File

@ -18,6 +18,7 @@ from tools.browser_camofox import (
camofox_scroll,
camofox_snapshot,
camofox_type,
get_camofox_url,
)
@ -42,6 +43,51 @@ class TestAuthHeaders:
monkeypatch.setenv("CAMOFOX_API_KEY", " ")
assert _auth_headers() == {}
def test_multiplex_scope_key_wins_over_process_environment(self, monkeypatch):
from agent import secret_scope
monkeypatch.setenv("CAMOFOX_API_KEY", "default-profile-key")
secret_scope.set_multiplex_active(True)
token = secret_scope.set_secret_scope({"CAMOFOX_API_KEY": "secondary-profile-key"})
try:
assert _auth_headers() == {"Authorization": "Bearer secondary-profile-key"}
finally:
secret_scope.reset_secret_scope(token)
secret_scope.set_multiplex_active(False)
def test_multiplex_scope_missing_key_fails_closed(self, monkeypatch):
from agent import secret_scope
monkeypatch.setenv("CAMOFOX_API_KEY", "default-profile-key")
secret_scope.set_multiplex_active(True)
token = secret_scope.set_secret_scope({})
try:
assert _auth_headers() == {}
finally:
secret_scope.reset_secret_scope(token)
secret_scope.set_multiplex_active(False)
def test_multiplex_scope_keeps_endpoint_and_key_in_same_profile(self, monkeypatch):
from agent import secret_scope
monkeypatch.setenv("CAMOFOX_URL", "https://default.example")
monkeypatch.setenv("CAMOFOX_API_KEY", "default-profile-key")
secret_scope.set_multiplex_active(True)
token = secret_scope.set_secret_scope(
{
"CAMOFOX_URL": "https://secondary.example/",
"CAMOFOX_API_KEY": "secondary-profile-key",
}
)
try:
assert get_camofox_url() == "https://secondary.example"
assert _auth_headers() == {
"Authorization": "Bearer secondary-profile-key"
}
finally:
secret_scope.reset_secret_scope(token)
secret_scope.set_multiplex_active(False)
class TestAuthHeadersSent:
"""Verify all HTTP call sites include auth headers when CAMOFOX_API_KEY is set."""

View File

@ -58,6 +58,64 @@ class TestCheckRequirements:
monkeypatch.setenv("DISCORD_BOT_TOKEN", " my-token ")
assert _get_bot_token() == "my-token"
def test_multiplex_scope_token_wins_over_process_environment(self, monkeypatch):
from agent import secret_scope
monkeypatch.setenv("DISCORD_BOT_TOKEN", "another-profile-token")
secret_scope.set_multiplex_active(True)
scope_token = secret_scope.set_secret_scope(
{"DISCORD_BOT_TOKEN": " active-profile-token "}
)
try:
assert _get_bot_token() == "active-profile-token"
assert check_discord_tool_requirements() is True
finally:
secret_scope.reset_secret_scope(scope_token)
secret_scope.set_multiplex_active(False)
def test_multiplex_scope_missing_token_fails_closed(self, monkeypatch):
from agent import secret_scope
from tools.registry import invalidate_check_fn_cache, registry
monkeypatch.setenv("DISCORD_BOT_TOKEN", "another-profile-token")
secret_scope.set_multiplex_active(True)
scope_token = secret_scope.set_secret_scope({"UNRELATED_SECRET": "value"})
invalidate_check_fn_cache()
try:
assert _get_bot_token() is None
assert check_discord_tool_requirements() is False
assert registry.get_definitions({"discord", "discord_admin"}) == []
finally:
invalidate_check_fn_cache()
secret_scope.reset_secret_scope(scope_token)
secret_scope.set_multiplex_active(False)
def test_non_multiplex_scope_miss_keeps_environment_compatibility(self, monkeypatch):
from agent import secret_scope
monkeypatch.setenv("DISCORD_BOT_TOKEN", "process-token")
secret_scope.set_multiplex_active(False)
scope_token = secret_scope.set_secret_scope({"UNRELATED_SECRET": "value"})
try:
assert _get_bot_token() == "process-token"
assert check_discord_tool_requirements() is True
finally:
secret_scope.reset_secret_scope(scope_token)
def test_gateway_tool_prompt_gate_uses_active_profile_token(self, monkeypatch):
from agent import secret_scope
from gateway.session import _discord_tools_loaded
monkeypatch.setenv("DISCORD_BOT_TOKEN", "another-profile-token")
secret_scope.set_multiplex_active(True)
scope_token = secret_scope.set_secret_scope({"UNRELATED_SECRET": "value"})
try:
with patch("hermes_cli.config.load_config") as load_config:
assert _discord_tools_loaded() is False
load_config.assert_not_called()
finally:
secret_scope.reset_secret_scope(scope_token)
secret_scope.set_multiplex_active(False)
# ---------------------------------------------------------------------------
# Channel type names

View File

@ -308,6 +308,40 @@ class TestCheckAvailable:
monkeypatch.setenv("HASS_TOKEN", "")
assert _check_ha_available() is False
def test_multiplex_scope_does_not_fall_back_to_another_profile(self, monkeypatch):
from agent import secret_scope
monkeypatch.setenv("HASS_TOKEN", "default-profile-token")
secret_scope.set_multiplex_active(True)
token = secret_scope.set_secret_scope({})
try:
assert _check_ha_available() is False
finally:
secret_scope.reset_secret_scope(token)
secret_scope.set_multiplex_active(False)
def test_multiplex_scope_supplies_profile_url_and_token(self, monkeypatch):
from agent import secret_scope
from tools.homeassistant_tool import _get_config
monkeypatch.setattr("tools.homeassistant_tool._HASS_URL", "")
monkeypatch.setattr("tools.homeassistant_tool._HASS_TOKEN", "")
monkeypatch.setenv("HASS_URL", "http://default-profile:8123")
monkeypatch.setenv("HASS_TOKEN", "default-profile-token")
secret_scope.set_multiplex_active(True)
token = secret_scope.set_secret_scope({
"HASS_URL": "http://secondary-profile:8123/",
"HASS_TOKEN": "secondary-profile-token",
})
try:
assert _get_config() == (
"http://secondary-profile:8123",
"secondary-profile-token",
)
finally:
secret_scope.reset_secret_scope(token)
secret_scope.set_multiplex_active(False)
# ---------------------------------------------------------------------------
# Auth headers

View File

@ -134,6 +134,125 @@ class TestCheckFnTransientFailureSuppression:
t["now"] += reg._CHECK_FN_FAILURE_GRACE_SECONDS + 1
assert reg._check_fn_cached(probe) is False
def test_profile_scoped_availability_does_not_cross_multiplex_profiles(
self, tmp_path
):
"""Both availability caches must use the active profile as a key."""
import tools.registry as reg
from agent.secret_scope import (
get_secret,
reset_secret_scope,
set_multiplex_active,
set_secret_scope,
)
from hermes_constants import (
reset_hermes_home_override,
set_hermes_home_override,
)
from model_tools import _clear_tool_defs_cache, get_tool_definitions
profile_a = tmp_path / "profiles" / "a"
profile_b = tmp_path / "profiles" / "b"
profile_a.mkdir(parents=True)
profile_b.mkdir(parents=True)
tool_name = "profile_scoped_availability_probe"
def probe():
return bool(get_secret("PROFILE_CACHE_TEST_TOKEN"))
reg.registry.register(
name=tool_name,
toolset="profile-cache-test",
schema={
"name": tool_name,
"description": "test-only profile-scoped availability probe",
"parameters": {"type": "object", "properties": {}},
},
handler=lambda _args: "ok",
check_fn=probe,
)
set_multiplex_active(True)
try:
home_a = set_hermes_home_override(str(profile_a))
secrets_a = set_secret_scope({"PROFILE_CACHE_TEST_TOKEN": "token-a"})
try:
tools_a = get_tool_definitions(
enabled_toolsets=["profile-cache-test"],
quiet_mode=True,
skip_tool_search_assembly=True,
)
finally:
reset_secret_scope(secrets_a)
reset_hermes_home_override(home_a)
home_b = set_hermes_home_override(str(profile_b))
secrets_b = set_secret_scope({})
try:
tools_b = get_tool_definitions(
enabled_toolsets=["profile-cache-test"],
quiet_mode=True,
skip_tool_search_assembly=True,
)
finally:
reset_secret_scope(secrets_b)
reset_hermes_home_override(home_b)
assert tool_name in {tool["function"]["name"] for tool in tools_a}
assert tool_name not in {tool["function"]["name"] for tool in tools_b}
finally:
set_multiplex_active(False)
reg.registry.deregister(tool_name)
reg.invalidate_check_fn_cache()
_clear_tool_defs_cache()
def test_unscoped_multiplex_request_bypasses_cache(self, monkeypatch):
"""An unknown profile must never share another request's cache entry."""
import model_tools
import tools.registry as reg
from agent.secret_scope import set_multiplex_active
values = iter([True, False])
definition_calls = {"n": 0}
def probe():
return next(values)
def compute_definitions(*_args, **_kwargs):
definition_calls["n"] += 1
return []
set_multiplex_active(True)
monkeypatch.setattr(model_tools, "_compute_tool_definitions", compute_definitions)
try:
assert reg._check_fn_cached(probe) is True
assert reg._check_fn_cached(probe) is False
assert not reg._check_fn_cache
model_tools.get_tool_definitions(quiet_mode=True)
model_tools.get_tool_definitions(quiet_mode=True)
assert definition_calls["n"] == 2
assert not model_tools._tool_defs_cache
finally:
set_multiplex_active(False)
reg.invalidate_check_fn_cache()
model_tools._clear_tool_defs_cache()
def test_profile_scoped_check_cache_is_bounded(self, monkeypatch):
"""Many multiplex profiles must not grow registry caches forever."""
import tools.registry as reg
scopes = iter(f"/profiles/{index}" for index in range(1_000))
monkeypatch.setattr(reg, "check_fn_cache_scope", lambda: next(scopes))
def available():
return True
for _ in range(1_000):
assert reg._check_fn_cached(available) is True
assert len(reg._check_fn_cache) <= reg._CHECK_FN_CACHE_MAX
assert len(reg._check_fn_last_good) <= reg._CHECK_FN_CACHE_MAX
def test_subagent_keeps_file_tools_through_docker_flake(self, monkeypatch):
"""End-to-end: a docker probe that flakes on the 2nd build keeps the
file/terminal toolset available for the subagent being constructed."""

View File

@ -36,6 +36,7 @@ from urllib.parse import SplitResult, urlsplit, urlunsplit
import requests
from agent.secret_scope import get_secret
from hermes_cli.config import cfg_get, load_config, read_raw_config
from tools.browser_camofox_state import get_camofox_identity
from tools.registry import tool_error
@ -82,7 +83,7 @@ def _get_command_timeout() -> int:
def _auth_headers() -> Dict[str, str]:
"""Return Authorization header when CAMOFOX_API_KEY is set."""
key = os.getenv("CAMOFOX_API_KEY", "").strip()
key = (get_secret("CAMOFOX_API_KEY", "") or "").strip()
if key:
return {"Authorization": f"Bearer {key}"}
return {}
@ -90,7 +91,7 @@ def _auth_headers() -> Dict[str, str]:
def get_camofox_url() -> str:
"""Return the configured Camofox server URL, or empty string."""
return os.getenv("CAMOFOX_URL", "").rstrip("/")
return (get_secret("CAMOFOX_URL", "") or "").rstrip("/")
def _config_cdp_url() -> str:

View File

@ -27,13 +27,13 @@ actionable guidance the model can relay to the user.
import json
import logging
import os
import threading
import urllib.error
import urllib.parse
import urllib.request
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
from agent.secret_scope import get_secret
from tools.registry import registry, tool_error
if TYPE_CHECKING:
@ -72,8 +72,8 @@ def _read_limited_response_body(source: Any, limit: int, *, label: str) -> bytes
def _get_bot_token() -> Optional[str]:
"""Resolve the Discord bot token from environment."""
return os.getenv("DISCORD_BOT_TOKEN", "").strip() or None
"""Resolve the Discord bot token under the active profile secret scope."""
return (get_secret("DISCORD_BOT_TOKEN", "") or "").strip() or None
def _discord_request(

View File

@ -13,10 +13,11 @@ The HA instance URL is read from ``HASS_URL`` (default: http://homeassistant.loc
import asyncio
import json
import logging
import os
import re
from typing import Any, Dict, Optional
from agent.secret_scope import get_secret
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
@ -29,10 +30,10 @@ _HASS_TOKEN: str = ""
def _get_config():
"""Return (hass_url, hass_token) from env vars at call time."""
"""Return the active profile's Home Assistant URL and token."""
return (
(_HASS_URL or os.getenv("HASS_URL", "http://homeassistant.local:8123")).rstrip("/"),
_HASS_TOKEN or os.getenv("HASS_TOKEN", ""),
(_HASS_URL or get_secret("HASS_URL", "http://homeassistant.local:8123") or "").rstrip("/"),
_HASS_TOKEN or get_secret("HASS_TOKEN", "") or "",
)
# Regex for valid HA entity_id format (e.g. "light.living_room", "sensor.temperature_1")
@ -343,7 +344,7 @@ def _handle_list_services(args: dict, **kw) -> str:
def _check_ha_available() -> bool:
"""Tool is only available when HASS_TOKEN is set."""
return bool(os.getenv("HASS_TOKEN"))
return bool(get_secret("HASS_TOKEN"))
# ---------------------------------------------------------------------------

View File

@ -218,10 +218,54 @@ _CHECK_FN_TTL_SECONDS = 30.0
# as a flake (last-good True is served) rather than a real outage. Kept short
# so a genuinely-down backend is reflected within a couple of turns.
_CHECK_FN_FAILURE_GRACE_SECONDS = 60.0
_check_fn_cache: Dict[Callable, tuple[float, bool]] = {}
_CHECK_FN_CACHE_MAX = 512
_check_fn_cache: Dict[tuple[Callable, Optional[str]], tuple[float, bool]] = {}
# Monotonic timestamp of the most recent True result per check_fn.
_check_fn_last_good: Dict[Callable, float] = {}
_check_fn_last_good: Dict[tuple[Callable, Optional[str]], float] = {}
_check_fn_cache_lock = threading.Lock()
CHECK_FN_CACHE_BYPASS = ""
def _prune_check_fn_caches(now: float) -> None:
"""Expire stale entries and cap profile-dimensional cache growth.
Caller must hold ``_check_fn_cache_lock``.
"""
for key, (timestamp, _) in list(_check_fn_cache.items()):
if now - timestamp >= _CHECK_FN_TTL_SECONDS:
_check_fn_cache.pop(key, None)
for key, timestamp in list(_check_fn_last_good.items()):
if now - timestamp >= _CHECK_FN_FAILURE_GRACE_SECONDS:
_check_fn_last_good.pop(key, None)
while len(_check_fn_cache) >= _CHECK_FN_CACHE_MAX:
_check_fn_cache.pop(next(iter(_check_fn_cache)))
while len(_check_fn_last_good) >= _CHECK_FN_CACHE_MAX:
_check_fn_last_good.pop(next(iter(_check_fn_last_good)))
def check_fn_cache_scope() -> Optional[str]:
"""Return the active profile key when availability is profile-scoped.
Single-profile processes intentionally keep the historical process-wide
cache. A multiplex gateway installs a Hermes-home override for every
profile turn, so the canonical profile key is the stable isolation
boundary across repeated turns for that profile.
"""
try:
from agent.secret_scope import is_multiplex_active
if not is_multiplex_active():
return None
from hermes_constants import get_hermes_home_override
override = get_hermes_home_override()
if not override:
return CHECK_FN_CACHE_BYPASS
return str(Path(override).expanduser().resolve())
except Exception:
# Fail closed: bypass both cache layers rather than aliasing requests
# whose multiplex profile identity could not be resolved.
return CHECK_FN_CACHE_BYPASS
def _check_fn_cached(fn: Callable) -> bool:
@ -234,8 +278,22 @@ def _check_fn_cached(fn: Callable) -> bool:
contention, probe timeout) from silently stripping tools mid-session.
"""
now = time.monotonic()
scope = check_fn_cache_scope()
if scope == CHECK_FN_CACHE_BYPASS:
try:
return bool(fn())
except Exception:
logger.warning(
"check_fn %s raised while profile cache scope was unresolved; "
"dependent tools will be unavailable this turn",
getattr(fn, "__qualname__", fn),
exc_info=True,
)
return False
cache_key = (fn, scope)
with _check_fn_cache_lock:
cached = _check_fn_cache.get(fn)
_prune_check_fn_caches(now)
cached = _check_fn_cache.get(cache_key)
if cached is not None:
ts, value = cached
if now - ts < _CHECK_FN_TTL_SECONDS:
@ -249,12 +307,13 @@ def _check_fn_cached(fn: Callable) -> bool:
raised = True
with _check_fn_cache_lock:
_prune_check_fn_caches(now)
if value:
_check_fn_last_good[fn] = now
_check_fn_cache[fn] = (now, True)
_check_fn_last_good[cache_key] = now
_check_fn_cache[cache_key] = (now, True)
return True
last_good = _check_fn_last_good.get(fn)
last_good = _check_fn_last_good.get(cache_key)
if last_good is not None and now - last_good < _CHECK_FN_FAILURE_GRACE_SECONDS:
# Recent success → treat this failure as a flake. Serve last-good
# True and do NOT cache the failure, so the next call re-probes
@ -275,7 +334,7 @@ def _check_fn_cached(fn: Callable) -> bool:
getattr(fn, "__qualname__", fn),
"raised" if raised else "returned False",
)
_check_fn_cache[fn] = (now, False)
_check_fn_cache[cache_key] = (now, False)
return False