diff --git a/tests/test_toolsets.py b/tests/test_toolsets.py index ff58b9e522..e59ce350f2 100644 --- a/tests/test_toolsets.py +++ b/tests/test_toolsets.py @@ -246,17 +246,29 @@ class TestResolveToolsetIncludeRegistry: by platform reverse-mapping. Regression harness for issue #49622.""" def test_include_registry_false_excludes_registry_tools(self): - from tools.registry import discover_builtin_tools - discover_builtin_tools() # registers read_terminal into 'terminal' + from tools.registry import discover_builtin_tools, registry + discover_builtin_tools() - merged = set(resolve_toolset("terminal")) - static = set(resolve_toolset("terminal", include_registry=False)) + # Register a tool into `terminal` at runtime, the way plugins and MCP + # servers do, so the split is exercised on the mechanism rather than on + # whichever built-in currently happens to live where. + registry.register( + name="__probe_registry_only_tool__", + toolset="terminal", + schema={"name": "__probe_registry_only_tool__", "parameters": {"type": "object", "properties": {}}}, + handler=lambda args, **kw: "", + ) + try: + merged = set(resolve_toolset("terminal")) + static = set(resolve_toolset("terminal", include_registry=False)) + finally: + registry.deregister("__probe_registry_only_tool__") assert static == {"terminal", "process"}, static - # read_terminal is registered into 'terminal' but is desktop-only and - # not part of the static definition — it must only appear in the merged view. - assert "read_terminal" in merged - assert "read_terminal" not in static + # Registered into 'terminal' but not part of the static definition — it + # must only appear in the merged view. + assert "__probe_registry_only_tool__" in merged + assert "__probe_registry_only_tool__" not in static def test_static_view_threads_through_includes(self): diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 019af04460..87eb31995c 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -1997,7 +1997,7 @@ def test_load_enabled_toolsets_folds_project_into_focus_posture(monkeypatch): monkeypatch.setattr(cc, "coding_selection", lambda **_: ["coding", "figma"]) - assert server._load_enabled_toolsets() == ["coding", "figma", "project"] + assert server._load_enabled_toolsets("tui") == ["coding", "figma", "project"] def test_load_enabled_toolsets_rejects_disabled_mcp_env(monkeypatch, capsys): @@ -3482,7 +3482,7 @@ def test_make_agent_passes_configured_fallback_chain(monkeypatch): }, ) monkeypatch.setattr("run_agent.AIAgent", fake_agent) - monkeypatch.setattr(server, "_load_enabled_toolsets", lambda: ["file"]) + monkeypatch.setattr(server, "_load_enabled_toolsets", lambda *_a, **_kw: ["file"]) monkeypatch.setattr(server, "_get_db", lambda: None) agent = server._make_agent("sid", "session-key") @@ -3503,7 +3503,7 @@ def test_background_agent_kwargs_preserves_full_fallback_chain(monkeypatch): _fallback_chain=chain, ) monkeypatch.setattr(server, "_load_cfg", lambda: {"max_turns": 25}) - monkeypatch.setattr(server, "_load_enabled_toolsets", lambda: ["file"]) + monkeypatch.setattr(server, "_load_enabled_toolsets", lambda *_a, **_kw: ["file"]) monkeypatch.setattr(server, "_get_db", lambda: None) kwargs = server._background_agent_kwargs(agent, "task-id") @@ -3527,7 +3527,7 @@ def test_background_agent_kwargs_preserves_empty_fallback_chain(monkeypatch): ], }, ) - monkeypatch.setattr(server, "_load_enabled_toolsets", lambda: ["file"]) + monkeypatch.setattr(server, "_load_enabled_toolsets", lambda *_a, **_kw: ["file"]) monkeypatch.setattr(server, "_get_db", lambda: None) kwargs = server._background_agent_kwargs(agent, "task-id") @@ -13498,7 +13498,7 @@ def _setup_make_agent_mocks(monkeypatch, cfg): monkeypatch.setattr(server, "_load_tool_progress_mode", lambda: "off") monkeypatch.setattr(server, "_load_reasoning_config", lambda model="": None) monkeypatch.setattr(server, "_load_service_tier", lambda: None) - monkeypatch.setattr(server, "_load_enabled_toolsets", lambda: None) + monkeypatch.setattr(server, "_load_enabled_toolsets", lambda *_a, **_kw: None) monkeypatch.setattr(server, "_get_db", lambda: None) monkeypatch.setattr(server, "_agent_cbs", lambda sid: {}) @@ -15496,7 +15496,7 @@ class TestResolveRuntimeWithFallback: fake_resolve, ) monkeypatch.setattr("run_agent.AIAgent", fake_agent) - monkeypatch.setattr(server, "_load_enabled_toolsets", lambda: ["file"]) + monkeypatch.setattr(server, "_load_enabled_toolsets", lambda *_a, **_kw: ["file"]) monkeypatch.setattr(server, "_get_db", lambda: None) agent = server._make_agent( diff --git a/tests/test_tui_mcp_late_refresh.py b/tests/test_tui_mcp_late_refresh.py index e3f423fba6..2791a8ac27 100644 --- a/tests/test_tui_mcp_late_refresh.py +++ b/tests/test_tui_mcp_late_refresh.py @@ -41,7 +41,7 @@ def _install(monkeypatch, *, in_flight, join_result, new_defs): monkeypatch.setattr(entry, "mcp_discovery_in_flight", lambda: in_flight) monkeypatch.setattr(entry, "join_mcp_discovery", lambda timeout=None: join_result) monkeypatch.setattr(model_tools, "get_tool_definitions", lambda **kw: list(new_defs)) - monkeypatch.setattr(server, "_load_enabled_toolsets", lambda: None) + monkeypatch.setattr(server, "_load_enabled_toolsets", lambda *_a, **_kw: None) monkeypatch.setattr(server, "_session_info", lambda agent, session: {"tools_len": len(agent.tools)}) emitted = [] diff --git a/tests/tools/test_focus_pane_tool.py b/tests/tools/test_focus_pane_tool.py index 57794332ea..bcc7e76580 100644 --- a/tests/tools/test_focus_pane_tool.py +++ b/tests/tools/test_focus_pane_tool.py @@ -1,10 +1,11 @@ -"""Tests for the desktop-gated ``focus_pane`` tool.""" +"""Tests for the GUI-surface ``focus_pane`` tool.""" import json import pytest from tools import desktop_ui, focus_pane_tool as fp +from tools.registry import registry @pytest.fixture(autouse=True) @@ -14,12 +15,16 @@ def _reset_emitter(): desktop_ui.set_emitter(None) -def test_gated_on_desktop(monkeypatch): +def test_lives_in_the_gui_surface_toolset(monkeypatch): + """Surface eligibility is the toolset's job, not a process env var — the + desktop client can be driving a remote/cloud backend that never sees + HERMES_DESKTOP.""" monkeypatch.delenv("HERMES_DESKTOP", raising=False) - assert fp.check_focus_pane_requirements() is False + entry = registry.get_entry("focus_pane") - monkeypatch.setenv("HERMES_DESKTOP", "1") - assert fp.check_focus_pane_requirements() is True + assert entry is not None + assert entry.toolset == "desktop_ui" + assert entry.check_fn is None @pytest.mark.parametrize("pane", fp.PANES) diff --git a/tests/tools/test_open_preview_tool.py b/tests/tools/test_open_preview_tool.py index c93d9870bf..dc2064e86b 100644 --- a/tests/tools/test_open_preview_tool.py +++ b/tests/tools/test_open_preview_tool.py @@ -1,10 +1,11 @@ -"""Tests for the desktop-gated ``open_preview`` tool.""" +"""Tests for the GUI-surface ``open_preview`` tool.""" import json import pytest from tools import desktop_ui, open_preview_tool as op +from tools.registry import registry @pytest.fixture(autouse=True) @@ -15,13 +16,15 @@ def _reset_emitter(): desktop_ui.set_emitter(None) -def test_gated_on_desktop(monkeypatch): - """Hidden unless HERMES_DESKTOP is set (mirrors read_terminal/close_terminal).""" +def test_lives_in_the_gui_surface_toolset(monkeypatch): + """Reaches a desktop client on ANY backend, including one with no + HERMES_DESKTOP in its environment (URL / cloud gateways).""" monkeypatch.delenv("HERMES_DESKTOP", raising=False) - assert op.check_open_preview_requirements() is False + entry = registry.get_entry("open_preview") - monkeypatch.setenv("HERMES_DESKTOP", "1") - assert op.check_open_preview_requirements() is True + assert entry is not None + assert entry.toolset == "desktop_ui" + assert entry.check_fn is None def test_emitter_failure_is_reported(): diff --git a/tests/tools/test_read_preview_tool.py b/tests/tools/test_read_preview_tool.py index 3bbc03fb47..04cb8f93ae 100644 --- a/tests/tools/test_read_preview_tool.py +++ b/tests/tools/test_read_preview_tool.py @@ -1,17 +1,19 @@ -"""Tests for the desktop-gated ``read_preview`` tool.""" +"""Tests for the GUI-surface ``read_preview`` tool.""" import json from tools import read_preview_tool as rp +from tools.registry import registry -def test_gated_on_desktop(monkeypatch): - """Hidden unless HERMES_DESKTOP is set (mirrors read_terminal).""" +def test_lives_in_the_gui_surface_toolset(monkeypatch): + """Mirrors read_terminal: scoped by toolset, not by the backend's env.""" monkeypatch.delenv("HERMES_DESKTOP", raising=False) - assert rp.check_read_preview_requirements() is False + entry = registry.get_entry("read_preview") - monkeypatch.setenv("HERMES_DESKTOP", "1") - assert rp.check_read_preview_requirements() is True + assert entry is not None + assert entry.toolset == "desktop_ui" + assert entry.check_fn is None def test_requires_callback(): diff --git a/tests/tui_gateway/test_gui_surface_toolsets.py b/tests/tui_gateway/test_gui_surface_toolsets.py new file mode 100644 index 0000000000..14e7601118 --- /dev/null +++ b/tests/tui_gateway/test_gui_surface_toolsets.py @@ -0,0 +1,113 @@ +"""GUI capability follows the SESSION's client, not the backend's process env. + +The desktop app is a client. It can drive a backend that Electron spawned +locally, one reached over SSH, one behind a plain URL+token, or Hermes Cloud — +and only the first two run with ``HERMES_DESKTOP=1`` in their environment. +Gating the pane/browser/reaction tools on that env var therefore stripped every +one of them from URL and cloud gateways, while the same backend still told the +model "You are chatting inside the Hermes desktop app". + +These tests pin the contract that replaced it: eligibility is resolved from the +session's own ``source`` (``session.create``'s ``source: 'desktop'``), so the +answer is identical on every connection topology. +""" + +import pytest + +import tui_gateway.server as server +from toolsets import TOOLSETS, resolve_toolset + +GUI_TOOLS = { + "close_terminal", + "focus_pane", + "open_preview", + "read_preview", + "read_terminal", + "react_to_message", +} + + +@pytest.fixture +def no_desktop_env(monkeypatch): + """A backend nobody told about the desktop — i.e. every remote gateway.""" + monkeypatch.delenv("HERMES_DESKTOP", raising=False) + monkeypatch.delenv("HERMES_DESKTOP_TERMINAL", raising=False) + monkeypatch.delenv("HERMES_TUI_TOOLSETS", raising=False) + return monkeypatch + + +class TestDesktopUiToolset: + def test_holds_exactly_the_gui_affordances(self): + assert set(resolve_toolset("desktop_ui")) == GUI_TOOLS + + def test_stays_off_the_core_tool_list(self): + """Core ships on every API call — a GUI-only tool must not be there.""" + from toolsets import _HERMES_CORE_TOOLS + + assert GUI_TOOLS.isdisjoint(_HERMES_CORE_TOOLS) + + def test_no_platform_bundle_carries_it(self): + """Messaging/CLI bundles must not pick these up by listing them.""" + for name, spec in TOOLSETS.items(): + if name == "desktop_ui": + continue + assert GUI_TOOLS.isdisjoint(set(spec.get("tools") or ())), name + + +class TestSurfaceResolution: + def test_desktop_session_gets_them_with_no_desktop_env(self, no_desktop_env): + """THE regression: a desktop client on a remote/cloud backend.""" + assert "desktop_ui" in server._gui_surface_toolsets("desktop") + + def test_tui_session_does_not(self, no_desktop_env): + assert "desktop_ui" not in server._gui_surface_toolsets("tui") + + def test_desktop_env_alone_does_not_grant_them(self, no_desktop_env): + """A desktop-spawned backend serving a TUI session stays clean. + + The embedded terminal pane runs `hermes --tui` against this same + backend; env-keyed gating handed it GUI tools it cannot answer. + """ + no_desktop_env.setenv("HERMES_DESKTOP", "1") + assert "desktop_ui" not in server._gui_surface_toolsets("tui") + + def test_project_tools_ride_on_every_gui_surface(self, no_desktop_env): + for platform in ("desktop", "tui"): + assert "project" in server._gui_surface_toolsets(platform) + + +class TestResolverPlumbing: + def test_posture_path_folds_in_the_session_surface(self, no_desktop_env): + """Focus-mode returns early — the surface toolsets must survive it.""" + import agent.coding_context as cc + + no_desktop_env.setattr(cc, "coding_selection", lambda **_: ["coding"]) + + assert server._load_enabled_toolsets("desktop") == [ + "coding", + "desktop_ui", + "project", + ] + assert server._load_enabled_toolsets("tui") == ["coding", "project"] + + def test_config_path_folds_in_the_session_surface(self, no_desktop_env): + import agent.coding_context as cc + import hermes_cli.config as config_mod + + no_desktop_env.setattr(cc, "coding_selection", lambda **_: None) + no_desktop_env.setattr( + config_mod, "load_config", lambda: {"platform_toolsets": {"cli": ["memory"]}} + ) + + desktop = server._load_enabled_toolsets("desktop") + tui = server._load_enabled_toolsets("tui") + + assert desktop is not None and tui is not None + assert "desktop_ui" in desktop + assert "desktop_ui" not in tui + + def test_explicit_env_pin_still_wins(self, no_desktop_env): + """HERMES_TUI_TOOLSETS is an operator override; surface can't re-add.""" + no_desktop_env.setenv("HERMES_TUI_TOOLSETS", "web,memory") + + assert server._load_enabled_toolsets("desktop") == ["web", "memory"] diff --git a/tools/close_terminal_tool.py b/tools/close_terminal_tool.py index b44d385112..24277f44da 100644 --- a/tools/close_terminal_tool.py +++ b/tools/close_terminal_tool.py @@ -8,14 +8,12 @@ The output keeps buffering and the user can reopen the tab from the status stack It routes through the process registry's ``on_close`` sink, which the desktop gateway wires to emit a ``terminal.close`` event the renderer handles. Like -``read_terminal`` it is gated on ``HERMES_DESKTOP`` so it never appears outside -the GUI. +``read_terminal`` it lives in the ``desktop_ui`` toolset, which the GUI gateway +enables only for desktop-sourced sessions, so it never appears outside the GUI. """ import json -from utils import env_var_enabled - from tools.process_registry import process_registry from tools.registry import registry, tool_error @@ -29,11 +27,6 @@ def close_terminal_tool(process_id: str) -> str: return json.dumps(process_registry.request_close_terminal(pid), ensure_ascii=False) -def check_close_terminal_requirements() -> bool: - """Desktop GUI only — HERMES_DESKTOP is set on the gateway the app spawns.""" - return env_var_enabled("HERMES_DESKTOP") - - CLOSE_TERMINAL_SCHEMA = { "name": "close_terminal", "description": ( @@ -62,9 +55,8 @@ CLOSE_TERMINAL_SCHEMA = { registry.register( name="close_terminal", - toolset="terminal", + toolset="desktop_ui", schema=CLOSE_TERMINAL_SCHEMA, handler=lambda args, **kw: close_terminal_tool(process_id=args.get("process_id", "")), - check_fn=check_close_terminal_requirements, emoji="🖥️", ) diff --git a/tools/focus_pane_tool.py b/tools/focus_pane_tool.py index d854a1ab0f..066341876a 100644 --- a/tools/focus_pane_tool.py +++ b/tools/focus_pane_tool.py @@ -1,17 +1,17 @@ #!/usr/bin/env python3 """Reveal/focus a pane in the Hermes desktop GUI. -Gated on ``HERMES_DESKTOP`` (like the other GUI affordances). Emits -``pane.reveal`` through the shared ``desktop_ui`` bridge; the renderer runs each -pane's own reveal path and only acts on the active window (a background turn -never moves the user's focus). To show a URL/file, use ``open_preview``. +Lives in the ``desktop_ui`` toolset (like the other GUI affordances), which the +GUI gateway enables only for desktop-sourced sessions. Emits ``pane.reveal`` +through the shared ``desktop_ui`` bridge; the renderer runs each pane's own +reveal path and only acts on the active window (a background turn never moves +the user's focus). To show a URL/file, use ``open_preview``. """ import json from tools import desktop_ui from tools.registry import registry, tool_error -from utils import env_var_enabled PANES = ("chat", "files", "terminal", "review", "sessions") @@ -32,11 +32,6 @@ def focus_pane_tool(pane: str) -> str: return json.dumps({"success": True, "pane": name}, ensure_ascii=False) -def check_focus_pane_requirements() -> bool: - """Desktop GUI only — HERMES_DESKTOP is set on the gateway the app spawns.""" - return env_var_enabled("HERMES_DESKTOP") - - FOCUS_PANE_SCHEMA = { "name": "focus_pane", "description": ( @@ -62,9 +57,8 @@ FOCUS_PANE_SCHEMA = { registry.register( name="focus_pane", - toolset="terminal", + toolset="desktop_ui", schema=FOCUS_PANE_SCHEMA, handler=lambda args, **kw: focus_pane_tool(pane=args.get("pane", "")), - check_fn=check_focus_pane_requirements, emoji="🪟", ) diff --git a/tools/open_preview_tool.py b/tools/open_preview_tool.py index 96b4d847e4..f230b563d0 100644 --- a/tools/open_preview_tool.py +++ b/tools/open_preview_tool.py @@ -1,10 +1,12 @@ #!/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. +Lives in the ``desktop_ui`` toolset, which the GUI gateway enables only for a +session whose source is the desktop app — so the schema never reaches a CLI, +messaging, or cron agent, and it DOES reach a desktop client on a remote/cloud +backend. 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 @@ -12,7 +14,6 @@ import re from tools import desktop_ui from tools.registry import registry, tool_error -from utils import env_var_enabled def _normalize_target(raw: str) -> str: @@ -52,11 +53,6 @@ def open_preview_tool(url: str, label: str = "") -> str: return json.dumps({"success": True, "url": target, "label": label}, ensure_ascii=False) -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") - - OPEN_PREVIEW_SCHEMA = { "name": "open_preview", "description": ( @@ -89,9 +85,8 @@ OPEN_PREVIEW_SCHEMA = { registry.register( name="open_preview", - toolset="terminal", + toolset="desktop_ui", schema=OPEN_PREVIEW_SCHEMA, handler=lambda args, **kw: open_preview_tool(url=args.get("url", ""), label=args.get("label", "")), - check_fn=check_open_preview_requirements, emoji="🖼️", ) diff --git a/tools/react_to_message_tool.py b/tools/react_to_message_tool.py index 5a6b52e5ef..a79bb25508 100644 --- a/tools/react_to_message_tool.py +++ b/tools/react_to_message_tool.py @@ -4,9 +4,9 @@ The conversational counterpart to the user's tapback: the same reaction store, the same one-per-author semantics, just written with ``author="agent"``. -Gated on ``HERMES_DESKTOP`` (like the other GUI affordances) so it costs nothing -on every other surface — the platform adapters already expose reactions through -``send_message(action="react")``, and this is the desktop's equivalent. +Lives in the ``desktop_ui`` toolset (like the other GUI affordances) so it costs +nothing on every other surface — the platform adapters already expose reactions +through ``send_message(action="react")``, and this is the desktop's equivalent. Defaults to the message that triggered this turn (the photon precedent: the model shouldn't have to thread row ids through tool calls), and emits @@ -90,14 +90,13 @@ def react_to_message_tool(emoji: str, message_row_id=None, messages_back=None) - def check_react_requirements() -> bool: - """Desktop GUI only, and opt-in. + """Opt-in feature flag — surface eligibility is the toolset's job. - HERMES_DESKTOP is set on the gateway the app spawns; the feature itself is - off by default and enabled from Settings → Appearance (the desktop mirrors - the toggle into ``display.message_reactions``). + ``desktop_ui`` already restricts this to GUI sessions. What's left is the + user's own toggle (Settings → Appearance), which the desktop mirrors into + ``display.message_reactions`` on the CONNECTED gateway's config — so this + reads the right config whether that gateway is local, SSH, URL, or cloud. """ - if not env_var_enabled("HERMES_DESKTOP"): - return False try: from hermes_cli.config import load_config_readonly @@ -155,7 +154,7 @@ REACT_TO_MESSAGE_SCHEMA = { registry.register( name="react_to_message", - toolset="terminal", + toolset="desktop_ui", schema=REACT_TO_MESSAGE_SCHEMA, handler=lambda args, **kw: react_to_message_tool( emoji=args.get("emoji", ""), diff --git a/tools/read_preview_tool.py b/tools/read_preview_tool.py index cbe32d74f9..21376ab390 100644 --- a/tools/read_preview_tool.py +++ b/tools/read_preview_tool.py @@ -7,13 +7,15 @@ bridge — the same one ``read_terminal`` uses: tui_gateway emits ``preview.read.request``, the renderer serializes the active preview tab and answers with ``preview.read.respond``. This module is just schema + a thin dispatcher over the platform-injected callback. + +Lives in the ``desktop_ui`` toolset, which the GUI gateway enables only for +desktop-sourced sessions. """ import json from typing import Callable, Optional from tools.registry import registry, tool_error -from utils import env_var_enabled def read_preview_tool( @@ -49,11 +51,6 @@ def read_preview_tool( return json.dumps({"text": str(raw)}, ensure_ascii=False) -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") - - READ_PREVIEW_SCHEMA = { "name": "read_preview", "description": ( @@ -86,13 +83,12 @@ READ_PREVIEW_SCHEMA = { registry.register( name="read_preview", - toolset="terminal", + toolset="desktop_ui", schema=READ_PREVIEW_SCHEMA, handler=lambda args, **kw: read_preview_tool( start=args.get("start"), count=args.get("count"), callback=kw.get("callback"), ), - check_fn=check_read_preview_requirements, emoji="🔍", ) diff --git a/tools/read_terminal_tool.py b/tools/read_terminal_tool.py index cfd10e2079..5cd977c0ea 100644 --- a/tools/read_terminal_tool.py +++ b/tools/read_terminal_tool.py @@ -6,13 +6,15 @@ tool round-trips through the gateway's blocking-prompt bridge — the same one `clarify` uses: tui_gateway emits ``terminal.read.request``, the renderer answers with ``terminal.read.respond``. This module is just schema + a thin dispatcher over the platform-injected callback. + +Lives in the ``desktop_ui`` toolset, which the GUI gateway enables only for +desktop-sourced sessions. """ import json from typing import Callable, Optional from tools.registry import registry, tool_error -from utils import env_var_enabled def read_terminal_tool( @@ -48,11 +50,6 @@ def read_terminal_tool( return json.dumps({"text": str(raw)}, ensure_ascii=False) -def check_read_terminal_requirements() -> bool: - """Desktop GUI only — HERMES_DESKTOP is set on the gateway the app spawns.""" - return env_var_enabled("HERMES_DESKTOP") - - READ_TERMINAL_SCHEMA = { "name": "read_terminal", "description": ( @@ -81,13 +78,12 @@ READ_TERMINAL_SCHEMA = { registry.register( name="read_terminal", - toolset="terminal", + toolset="desktop_ui", schema=READ_TERMINAL_SCHEMA, handler=lambda args, **kw: read_terminal_tool( start_line=args.get("start_line"), count=args.get("count"), callback=kw.get("callback"), ), - check_fn=check_read_terminal_requirements, emoji="🖥️", ) diff --git a/toolsets.py b/toolsets.py index 41f31426cb..e713858c23 100644 --- a/toolsets.py +++ b/toolsets.py @@ -33,11 +33,13 @@ _HERMES_CORE_TOOLS = [ "web_search", "web_extract", # Terminal + process management "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). - "read_terminal", "close_terminal", "open_preview", "read_preview", "focus_pane", "react_to_message", + # NOTE: the desktop GUI affordances (read_terminal, open_preview, …) are + # deliberately NOT here, for the same reason as the `project` tools below: + # they only work where a GUI renderer can answer them. They live in the + # `desktop_ui` toolset and are enabled solely by the GUI gateway for a + # session whose SOURCE is the desktop app (tui_gateway/server.py:: + # _load_enabled_toolsets) — never keyed on a process env var, which is + # blind to a desktop client talking to a remote/cloud backend. # File manipulation "read_file", "write_file", "patch", "search_files", # Vision + image generation @@ -256,6 +258,25 @@ TOOLSETS = { "tools": ["project_list", "project_create", "project_switch"], "includes": [] }, + + # Affordances that only exist because a GUI renderer is on the other end of + # the connection: read/close the embedded terminal pane, open and read the + # in-app browser, focus a pane, tapback a message. + # + # Enabled by the GUI gateway for a session whose SOURCE is the desktop app + # (tui_gateway/server.py::_load_enabled_toolsets), NOT by a process env var. + # The renderer is a CLIENT — it can be driving a local, SSH, URL, or cloud + # backend — so "was this process spawned by Electron?" is the wrong + # question and silently strips these tools from every remote gateway. + "desktop_ui": { + "description": "Desktop GUI affordances — in-app terminal/browser panes, pane focus, reactions (GUI sessions only)", + "tools": [ + "read_terminal", "close_terminal", + "open_preview", "read_preview", + "focus_pane", "react_to_message", + ], + "includes": [] + }, "clarify": { "description": "Ask the user clarifying questions (multiple-choice or open-ended)", @@ -371,12 +392,16 @@ TOOLSETS = { # code workspace; see agent/coding_context.py. Keeps everything you reach # for while pairing on code and drops the rest (messaging, tts, image_gen, # spotify, home-assistant, cron, computer-use). + # + # The GUI pane/browser affordances are NOT listed here: they belong to the + # client surface, not the posture, so the GUI gateway folds `desktop_ui` + # in alongside this selection for a desktop-sourced session (see + # tui_gateway/server.py::_load_enabled_toolsets). "coding": { "description": "Coding-focused toolset: files, terminal, search, web docs, skills, todo, delegate, vision, browser", "tools": [ "web_search", "web_extract", - "terminal", "process", "read_terminal", "close_terminal", - "open_preview", "read_preview", + "terminal", "process", "read_file", "write_file", "patch", "search_files", "vision_analyze", "skills_list", "skill_view", "skill_manage", diff --git a/tui_gateway/server.py b/tui_gateway/server.py index f9e1981e1a..cc015d3600 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -4086,7 +4086,29 @@ def _load_tool_progress_mode() -> str: return mode if mode in {"off", "new", "all", "verbose"} else "all" -def _load_enabled_toolsets() -> list[str] | None: +def _gui_surface_toolsets(platform: str) -> set[str]: + """Toolsets that exist because of the CLIENT on the other end, not the host. + + Both entries are deliberately off ``_HERMES_CORE_TOOLS`` — every other + platform would carry their schema for nothing — so this resolver is the one + gate that exposes them. + + ``platform`` is the SESSION's source (``session.create``'s ``source`` + field), never a process env var. The desktop app is a client: it can be + driving a local, SSH, URL, or cloud backend, and only the local/SSH spawn + paths run with ``HERMES_DESKTOP=1``. Keying GUI capability off that env var + silently stripped every pane/browser tool from URL and cloud gateways while + the same backend told the model it was "chatting inside the Hermes desktop + app". See the surface-capability rule in AGENTS.md. + """ + surfaces = {"project"} + if platform == "desktop": + surfaces.add("desktop_ui") + return surfaces + + +def _load_enabled_toolsets(platform: str | None = None) -> list[str] | None: + session_platform = platform or _resolve_session_platform() explicit = [ item.strip() for item in os.environ.get("HERMES_TUI_TOOLSETS", "").split(",") @@ -4105,13 +4127,13 @@ def _load_enabled_toolsets() -> list[str] | None: try: from agent.coding_context import coding_selection - selection = coding_selection(platform=_resolve_session_platform()) + selection = coding_selection(platform=session_platform) if selection is not None: - # Fold in `project` here too: this is a GUI-only resolver, and - # the focus-mode coding posture returns before the fallback path - # that normally adds it — without this the desktop loses the - # project tools exactly when sitting in a repo (see below). - return sorted({*selection, "project"}) + # Fold in the client-surface toolsets here too: the focus-mode + # coding posture returns before the fallback path that normally + # adds them — without this the desktop loses its pane/project + # tools exactly when sitting in a repo (see below). + return sorted({*selection, *_gui_surface_toolsets(session_platform)}) except Exception: pass @@ -4222,13 +4244,13 @@ def _load_enabled_toolsets() -> list[str] | None: print(fallback_notice, file=sys.stderr, flush=True) if not enabled: return None - # The desktop Project tools are off _HERMES_CORE_TOOLS (every other + # The client-surface toolsets are off _HERMES_CORE_TOOLS (every other # platform would carry their schema for nothing), so the platform # recovery above — which keys off hermes-cli's tool universe — can't # surface them. This resolver runs ONLY in the desktop/TUI gateway, so - # folding in the `project` toolset here is the gate that exposes them on - # exactly the surface that can follow a project move. - return sorted(enabled | {"project"}) + # folding them in here is the gate that exposes them on exactly the + # surface that can answer them. + return sorted(enabled | _gui_surface_toolsets(session_platform)) except Exception: if fallback_notice is not None: print( @@ -5990,7 +6012,11 @@ def _background_agent_kwargs(agent, task_id: str) -> dict: "model": getattr(agent, "model", None) or _resolve_model(), "max_iterations": _cfg_max_turns(cfg, 25), "enabled_toolsets": getattr(agent, "enabled_toolsets", None) - or _load_enabled_toolsets(), + # Detached background tasks declare platform="tui" below: they have no + # UI session id, so a renderer-routed event has nowhere to land. Resolve + # their toolsets against that same platform rather than the gateway + # process's, so they never carry GUI schema they cannot use. + or _load_enabled_toolsets("tui"), "quiet_mode": True, "verbose_logging": False, "ephemeral_system_prompt": getattr(agent, "ephemeral_system_prompt", None) @@ -6485,7 +6511,7 @@ def _make_agent( if service_tier_override is not None else _load_service_tier() ), - enabled_toolsets=_load_enabled_toolsets(), + enabled_toolsets=_load_enabled_toolsets(_resolve_agent_platform(platform_override)), # OpenRouter provider-routing prefs (config.yaml `provider_routing`). # Mirrors the messaging gateway + CLI so the desktop/TUI honors the same # routing instead of letting OpenRouter pick providers at random.