fix(tools): isolate model tools by multiplex profile
This commit is contained in:
parent
153442dd5b
commit
76cf19fee1
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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``
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue