fix(agent): the desktop's tools reach it on remote and cloud backends too

The pane, in-app browser, and reaction tools were gated on HERMES_DESKTOP=1 —
an env var set only on backends Electron spawns itself (local and SSH). A
desktop client connected to a plain URL gateway or Hermes Cloud lost all six:
they were stripped from the schema before the model saw them, on the same
backend whose platform hint was telling it "you are chatting inside the Hermes
desktop app". open_preview, read_preview, read_terminal, close_terminal,
focus_pane, and react_to_message were all silently absent.

The client is not the host. Capability now resolves from the session's own
source, which session.create already carries:

- The six tools move into a `desktop_ui` toolset, off _HERMES_CORE_TOOLS so no
  other platform pays their schema.
- _gui_surface_toolsets(platform) folds `desktop_ui` (and the existing
  `project` tools) into the GUI gateway's resolution when the session's
  platform is the desktop app — the same answer on every topology.
- check_fn drops the env probe. It kept the one thing that is genuinely a
  per-process/user fact: react_to_message's display.message_reactions opt-in,
  which the desktop mirrors onto whichever gateway it is connected to.

react_to_message was doubly broken: it read that toggle behind the env gate, so
even a local-backend user's Settings toggle could not reach a remote session.

The embedded terminal pane keeps working correctly the other way round: it runs
`hermes --tui` against a desktop-spawned backend, and a tui-sourced session
gets no GUI tools even though HERMES_DESKTOP=1 is set on that process.
This commit is contained in:
Brooklyn Nicholson
2026-08-06 20:27:00 -05:00
committed by brooklyn!
parent fe3a1cad6e
commit 7ad9ace2cc
15 changed files with 271 additions and 113 deletions
+20 -8
View File
@@ -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):
+6 -6
View File
@@ -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(
+1 -1
View File
@@ -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 = []
+10 -5
View File
@@ -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)
+9 -6
View File
@@ -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():
+8 -6
View File
@@ -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():
@@ -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"]
+3 -11
View File
@@ -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="🖥️",
)
+6 -12
View File
@@ -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="🪟",
)
+7 -12
View File
@@ -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="🖼️",
)
+9 -10
View File
@@ -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", ""),
+4 -8
View File
@@ -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="🔍",
)
+4 -8
View File
@@ -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="🖥️",
)
+32 -7
View File
@@ -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",
+39 -13
View File
@@ -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.