Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 18 additions & 5 deletions model_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
check_fn_cache_scope,
discover_builtin_tools,
registry,
tool_availability_platform,
tool_error,
)
from toolsets import resolve_toolset, validate_toolset
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
97 changes: 97 additions & 0 deletions tests/agent/test_desktop_tool_availability.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 1 addition & 1 deletion tests/tools/test_open_preview_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion tests/tools/test_read_preview_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 19 additions & 0 deletions tests/tools/test_refresh_agent_mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
1 change: 1 addition & 0 deletions tools/mcp_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 []
)
Expand Down
18 changes: 11 additions & 7 deletions tools/open_preview_tool.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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 = {
Expand Down
9 changes: 6 additions & 3 deletions tools/read_preview_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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 = {
Expand Down
35 changes: 30 additions & 5 deletions tools/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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 = ""

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions toolsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down