diff --git a/agent/agent_init.py b/agent/agent_init.py index 6f89ed237dca7..8ec75ccc6b597 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1433,6 +1433,7 @@ def init_agent( enabled_toolsets=enabled_toolsets, disabled_toolsets=disabled_toolsets, quiet_mode=agent.quiet_mode, + platform=agent.platform, ) # Show tool configuration and store valid tool names for validation diff --git a/model_tools.py b/model_tools.py index a4ed0c20c5771..6c1060db4888c 100644 --- a/model_tools.py +++ b/model_tools.py @@ -34,6 +34,7 @@ check_fn_cache_scope, discover_builtin_tools, registry, + tool_availability_platform, tool_error, ) from toolsets import resolve_toolset, validate_toolset @@ -273,8 +274,9 @@ def _run_in_worker(): # get_tool_definitions (the main schema provider) # ============================================================================= -# Module-level memoization for get_tool_definitions(). Keyed on -# (frozenset(enabled_toolsets), frozenset(disabled_toolsets), registry._generation). +# Module-level memoization for get_tool_definitions(). The key covers toolset +# filters, registry/config state, execution-context flags, profile scope, and +# the session platform used for availability checks. # Hot callers (gateway runner, AIAgent.__init__) invoke this on every turn # with quiet_mode=True; caching avoids ~7 ms of registry walking + schema # filtering + check_fn probing per call. Only active when quiet_mode=True @@ -307,6 +309,7 @@ def get_tool_definitions( disabled_toolsets: Optional[List[str]] = None, quiet_mode: bool = False, skip_tool_search_assembly: bool = False, + platform: Optional[str] = None, ) -> List[Dict[str, Any]]: """ Get tool definitions for model API calls with toolset-based filtering. @@ -322,6 +325,7 @@ def get_tool_definitions( tool_search / tool_describe bridge handlers so they can read the real catalog, not the already-collapsed one. Public callers should leave this False. + platform: Session surface whose platform-gated tools are being assembled. Returns: Filtered list of OpenAI-format tool definitions. @@ -335,6 +339,7 @@ def get_tool_definitions( # mode, discord action allowlist, etc.) without needing an explicit # invalidate hook on every config-writer. cache_key = None + availability_platform = str(platform or "").strip().lower() if quiet_mode: try: from hermes_cli.config import get_config_path @@ -355,6 +360,7 @@ def get_tool_definitions( _is_delegated_child_context(), _is_dispatcher_owned_worker(), profile_scope, + availability_platform, ) cached = _tool_defs_cache.get(cache_key) if cache_key is not None else None if cached is not None: @@ -366,8 +372,13 @@ def get_tool_definitions( # schemas are treated as read-only by all known callers. return list(cached) - result = _compute_tool_definitions(enabled_toolsets, disabled_toolsets, quiet_mode, - skip_tool_search_assembly=skip_tool_search_assembly) + result = _compute_tool_definitions( + enabled_toolsets, + disabled_toolsets, + quiet_mode, + skip_tool_search_assembly=skip_tool_search_assembly, + platform=availability_platform, + ) 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 @@ -393,6 +404,7 @@ def _compute_tool_definitions( disabled_toolsets: Optional[List[str]] = None, quiet_mode: bool = False, skip_tool_search_assembly: bool = False, + platform: Optional[str] = None, ) -> List[Dict[str, Any]]: """Uncached implementation of :func:`get_tool_definitions`.""" # Determine which tool names the caller wants @@ -481,7 +493,8 @@ def _compute_tool_definitions( # other toolset. # Ask the registry for schemas (only returns tools whose check_fn passes) - filtered_tools = registry.get_definitions(tools_to_include, quiet=quiet_mode) + with tool_availability_platform(platform): + filtered_tools = registry.get_definitions(tools_to_include, quiet=quiet_mode) # The set of tool names that actually passed check_fn filtering. # Use this (not tools_to_include) for any downstream schema that references diff --git a/tests/agent/test_desktop_tool_availability.py b/tests/agent/test_desktop_tool_availability.py new file mode 100644 index 0000000000000..0a429362d713b --- /dev/null +++ b/tests/agent/test_desktop_tool_availability.py @@ -0,0 +1,97 @@ +"""Desktop tool availability when the gateway is not Desktop-managed.""" + +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier + +from hermes_constants import reset_hermes_home_override, set_hermes_home_override +from run_agent import AIAgent +from tools.registry import invalidate_check_fn_cache + + +def test_remote_desktop_agent_includes_preview_tools_without_process_flag( + monkeypatch, tmp_path +): + """The session platform, not gateway launch ownership, defines the UI surface.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.delenv("HERMES_DESKTOP", raising=False) + monkeypatch.delenv("HERMES_DESKTOP_TERMINAL", raising=False) + + import model_tools + + model_tools._clear_tool_defs_cache() + invalidate_check_fn_cache() + home_token = set_hermes_home_override(tmp_path) + try: + agent = AIAgent( + api_key="test-key", + base_url="http://127.0.0.1:1/v1", + provider="custom", + model="anthropic/claude-sonnet-4.6", + platform="desktop", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + enabled_toolsets=["terminal"], + ) + finally: + reset_hermes_home_override(home_token) + + assert {"open_preview", "read_preview"} <= getattr(agent, "valid_tool_names") + + +def test_preview_tool_cache_isolated_between_desktop_and_cli(monkeypatch): + """A shared gateway must not leak Desktop-only schemas into other clients.""" + monkeypatch.delenv("HERMES_DESKTOP", raising=False) + monkeypatch.delenv("HERMES_DESKTOP_TERMINAL", raising=False) + + import model_tools + + model_tools._clear_tool_defs_cache() + invalidate_check_fn_cache() + + def preview_names(platform): + definitions = model_tools.get_tool_definitions( + enabled_toolsets=["terminal"], + quiet_mode=True, + platform=platform, + ) + return { + item["function"]["name"] + for item in definitions + if item["function"]["name"] in {"open_preview", "read_preview"} + } + + assert preview_names("desktop") == {"open_preview", "read_preview"} + assert preview_names("cli") == set() + + +def test_preview_tool_availability_context_is_thread_local(monkeypatch): + """Concurrent Desktop and non-Desktop builds must not share availability state.""" + monkeypatch.delenv("HERMES_DESKTOP", raising=False) + monkeypatch.delenv("HERMES_DESKTOP_TERMINAL", raising=False) + + import model_tools + + model_tools._clear_tool_defs_cache() + invalidate_check_fn_cache() + barrier = Barrier(2) + + def preview_names(platform): + barrier.wait() + definitions = model_tools.get_tool_definitions( + enabled_toolsets=["terminal"], + quiet_mode=True, + platform=platform, + ) + return { + item["function"]["name"] + for item in definitions + if item["function"]["name"] in {"open_preview", "read_preview"} + } + + with ThreadPoolExecutor(max_workers=2) as pool: + desktop = pool.submit(preview_names, "desktop") + cli = pool.submit(preview_names, "cli") + + assert desktop.result() == {"open_preview", "read_preview"} + assert cli.result() == set() diff --git a/tests/tools/test_open_preview_tool.py b/tests/tools/test_open_preview_tool.py index c93d9870bfe78..474c9421eec5d 100644 --- a/tests/tools/test_open_preview_tool.py +++ b/tests/tools/test_open_preview_tool.py @@ -16,7 +16,7 @@ def _reset_emitter(): def test_gated_on_desktop(monkeypatch): - """Hidden unless HERMES_DESKTOP is set (mirrors read_terminal/close_terminal).""" + """The legacy process flag still exposes the tool outside session assembly.""" monkeypatch.delenv("HERMES_DESKTOP", raising=False) assert op.check_open_preview_requirements() is False diff --git a/tests/tools/test_read_preview_tool.py b/tests/tools/test_read_preview_tool.py index 3bbc03fb47c6f..527865918a90c 100644 --- a/tests/tools/test_read_preview_tool.py +++ b/tests/tools/test_read_preview_tool.py @@ -6,7 +6,7 @@ def test_gated_on_desktop(monkeypatch): - """Hidden unless HERMES_DESKTOP is set (mirrors read_terminal).""" + """The legacy process flag still exposes the tool outside session assembly.""" monkeypatch.delenv("HERMES_DESKTOP", raising=False) assert rp.check_read_preview_requirements() is False diff --git a/tests/tools/test_refresh_agent_mcp_tools.py b/tests/tools/test_refresh_agent_mcp_tools.py index b1aa95e12f560..ef3733fed647a 100644 --- a/tests/tools/test_refresh_agent_mcp_tools.py +++ b/tests/tools/test_refresh_agent_mcp_tools.py @@ -27,6 +27,25 @@ def _agent(tool_names, *, enabled=None, disabled=None): return a +def test_refresh_preserves_remote_desktop_preview_tools(monkeypatch): + """MCP/tool reloads must rebuild using the agent's Desktop surface.""" + monkeypatch.delenv("HERMES_DESKTOP", raising=False) + monkeypatch.delenv("HERMES_DESKTOP_TERMINAL", raising=False) + + import model_tools + from tools.registry import invalidate_check_fn_cache + + model_tools._clear_tool_defs_cache() + invalidate_check_fn_cache() + + agent = _agent(["open_preview", "read_preview"], enabled=["terminal"]) + agent.platform = "desktop" + + mcp_tool.refresh_agent_mcp_tools(agent) + + assert {"open_preview", "read_preview"} <= agent.valid_tool_names + + def test_refresh_adds_late_landing_tools(monkeypatch): """A server that registers after build → its tools land in the snapshot.""" agent = _agent(["read_file", "terminal"]) diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 993d9a13c80f5..802211707787f 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -6791,6 +6791,7 @@ def refresh_agent_mcp_tools( enabled_toolsets=enabled, disabled_toolsets=disabled, quiet_mode=quiet_mode, + platform=getattr(agent, "platform", None), ) or [] ) diff --git a/tools/open_preview_tool.py b/tools/open_preview_tool.py index 96b4d847e464a..5feb9b0eb355d 100644 --- a/tools/open_preview_tool.py +++ b/tools/open_preview_tool.py @@ -1,17 +1,18 @@ #!/usr/bin/env python3 """Open a URL, dev server, or file in the Hermes desktop GUI's preview pane. -Gated on ``HERMES_DESKTOP`` (like ``read_terminal`` / ``close_terminal``) so it -never appears outside the GUI. Emits ``preview.open`` through the shared -``desktop_ui`` bridge; the renderer opens the pane beside the chat for the -window that asked and never steals focus for a background session. +Available to Desktop sessions, with ``HERMES_DESKTOP`` retained for locally +managed gateways, so it never appears outside the GUI. Emits ``preview.open`` +through the shared ``desktop_ui`` bridge; the renderer opens the pane beside +the chat for the window that asked and never steals focus for a background +session. """ import json import re from tools import desktop_ui -from tools.registry import registry, tool_error +from tools.registry import current_tool_availability_platform, registry, tool_error from utils import env_var_enabled @@ -53,8 +54,11 @@ def open_preview_tool(url: str, label: str = "") -> str: def check_open_preview_requirements() -> bool: - """Desktop GUI only — HERMES_DESKTOP is set on the gateway the app spawns.""" - return env_var_enabled("HERMES_DESKTOP") + """Desktop GUI only — including Desktop sessions on an external gateway.""" + return ( + env_var_enabled("HERMES_DESKTOP") + or current_tool_availability_platform() == "desktop" + ) OPEN_PREVIEW_SCHEMA = { diff --git a/tools/read_preview_tool.py b/tools/read_preview_tool.py index cbe32d74f99f9..412152a006dff 100644 --- a/tools/read_preview_tool.py +++ b/tools/read_preview_tool.py @@ -12,7 +12,7 @@ import json from typing import Callable, Optional -from tools.registry import registry, tool_error +from tools.registry import current_tool_availability_platform, registry, tool_error from utils import env_var_enabled @@ -50,8 +50,11 @@ def read_preview_tool( def check_read_preview_requirements() -> bool: - """Desktop GUI only — HERMES_DESKTOP is set on the gateway the app spawns.""" - return env_var_enabled("HERMES_DESKTOP") + """Desktop GUI only — including Desktop sessions on an external gateway.""" + return ( + env_var_enabled("HERMES_DESKTOP") + or current_tool_availability_platform() == "desktop" + ) READ_PREVIEW_SCHEMA = { diff --git a/tools/registry.py b/tools/registry.py index c92f8a0b9bcac..5c6d84a605391 100644 --- a/tools/registry.py +++ b/tools/registry.py @@ -21,12 +21,35 @@ import sys import threading import time +from contextlib import contextmanager +from contextvars import ContextVar from pathlib import Path -from typing import Callable, Dict, List, Optional, Set +from typing import Callable, Dict, Iterator, List, Optional, Set logger = logging.getLogger(__name__) +_TOOL_AVAILABILITY_PLATFORM: ContextVar[str] = ContextVar( + "tool_availability_platform", default="" +) + + +def current_tool_availability_platform() -> str: + """Return the session platform whose tool schemas are being assembled.""" + return _TOOL_AVAILABILITY_PLATFORM.get() + + +@contextmanager +def tool_availability_platform(platform: Optional[str]) -> Iterator[None]: + """Scope availability probes to one agent surface without mutating the process env.""" + normalized = str(platform or "").strip().lower() + token = _TOOL_AVAILABILITY_PLATFORM.set(normalized) + try: + yield + finally: + _TOOL_AVAILABILITY_PLATFORM.reset(token) + + def _is_registry_register_call(node: ast.AST) -> bool: """Return True when *node* is a ``registry.register(...)`` call expression.""" if not isinstance(node, ast.Expr) or not isinstance(node.value, ast.Call): @@ -219,9 +242,9 @@ def __init__(self, name, toolset, schema, handler, check_fn, # so a genuinely-down backend is reflected within a couple of turns. _CHECK_FN_FAILURE_GRACE_SECONDS = 60.0 _CHECK_FN_CACHE_MAX = 512 -_check_fn_cache: Dict[tuple[Callable, Optional[str]], tuple[float, bool]] = {} +_check_fn_cache: Dict[tuple[Callable, Optional[str], str], tuple[float, bool]] = {} # Monotonic timestamp of the most recent True result per check_fn. -_check_fn_last_good: Dict[tuple[Callable, Optional[str]], float] = {} +_check_fn_last_good: Dict[tuple[Callable, Optional[str], str], float] = {} _check_fn_cache_lock = threading.Lock() CHECK_FN_CACHE_BYPASS = "" @@ -290,7 +313,7 @@ def _check_fn_cached(fn: Callable) -> bool: exc_info=True, ) return False - cache_key = (fn, scope) + cache_key = (fn, scope, current_tool_availability_platform()) with _check_fn_cache_lock: _prune_check_fn_caches(now) cached = _check_fn_cache.get(cache_key) @@ -361,7 +384,9 @@ def get_cached_check_fn_result(fn: Callable) -> Optional[bool]: # trustworthy cached verdict to report. return None with _check_fn_cache_lock: - cached = _check_fn_cache.get((fn, scope)) + cached = _check_fn_cache.get( + (fn, scope, current_tool_availability_platform()) + ) if cached is None: return None ts, value = cached diff --git a/toolsets.py b/toolsets.py index 41f31426cb137..01b4a0cd7164c 100644 --- a/toolsets.py +++ b/toolsets.py @@ -35,8 +35,9 @@ "terminal", "process", # Desktop GUI affordances: read the embedded terminal pane, close an agent's # read-only terminal tab, open a URL/file in the preview pane, focus a - # pane, and react to a message with an emoji (all gated on HERMES_DESKTOP - # via check_fn — hidden outside the GUI). + # pane, and react to a message with an emoji (all use check_fn and are + # hidden outside the GUI; preview tools also accept external Desktop + # session context). "read_terminal", "close_terminal", "open_preview", "read_preview", "focus_pane", "react_to_message", # File manipulation "read_file", "write_file", "patch", "search_files",