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:
committed by
brooklyn!
parent
fe3a1cad6e
commit
7ad9ace2cc
+20
-8
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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"]
|
||||
@@ -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="🖥️",
|
||||
)
|
||||
|
||||
@@ -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="🪟",
|
||||
)
|
||||
|
||||
@@ -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="🖼️",
|
||||
)
|
||||
|
||||
@@ -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", ""),
|
||||
|
||||
@@ -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="🔍",
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -257,6 +259,25 @@ TOOLSETS = {
|
||||
"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)",
|
||||
"tools": ["clarify"],
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user