Files
hermes-agent/tools/react_to_message_tool.py
Teknium a5bd246865 Old pre-decomposition import paths are gone: plugin compat layer removed on schedule (#126164)
* refactor(plugins): remove the Sep 2026 decomposition compat layer on schedule

The PLUGIN-COMPAT layer (2776813df3 + d63e380324 + 0a5164cebe) kept pre-#102117 import paths
alive for external plugins until 2026-09-14. That window closed two weeks ago; since then the loader
has already been skipping plugins that use the old paths. This removes the layer itself:

- 328 appended `PLUGIN-COMPAT` blocks (lazy `__getattr__` pointer tables, re-exported third-party
  names, restored dead definitions) and the three re-export stub modules
  (gateway/startup_watchdog, hermes_cli/observability/relay_runtime, tools/environments/modal_utils)
- COMPAT_MANIFEST.md, compat_manifest.json, scripts/check_compat_pointers.py and its lint step
- the reporting surfaces: CLI banner notice, `hermes plugins compat`, the `hermes doctor` section,
  the post-update notice, the Desktop one-time dialog, the loader's pre-import skip and the
  `plugins.allow_deprecated_imports` escape hatch

An external plugin that still imports an old path now fails to load with its ImportError as the
reason in `hermes plugins list`, the same path as any broken plugin.

hermes_cli/plugin_compat.py stays as three inert stubs (compat_report, removal_in_effect,
summary_lines): an already-running pre-removal `hermes update` lazy-imports them after the checkout
swap (tests/compat/old_updater_surface.json).

In-tree fallout, both already dead: hermes_cli/setup.py::_check_espeak_ng (no callers; its
`shutil` came from a compat block) and gateway/config.py::SessionResetPolicy ("retained solely for
the scheduled plugin-compat window"). Two test_run_agent patches targeted the removed
`run_agent.handle_function_call` pointer; they now patch `model_tools.handle_function_call`, the
seam production reads, like every sibling test in that file.

* chore: retrigger CI (zero-job startup_failure phantom)

* test: drop resolution allowlist rows for the two deleted which() sites

hermes_cli/setup.py::_check_espeak_ng (dead) and tools/skillevaluator_scan.py::scanner_available
(a restored definition inside a PLUGIN-COMPAT block) no longer exist; the stale-row gate requires
their allowlist entries go with them.
2026-09-28 10:21:41 -07:00

119 lines
5.4 KiB
Python

#!/usr/bin/env python3
"""Agent emoji reaction in the Hermes desktop app: the counterpart to the user's tapback
(same store, one-per-author, ``author="agent"``). Lives in the ``desktop_ui`` toolset so it
costs nothing elsewhere (adapters expose reactions via ``send_message(action="react")``);
defaults to the triggering message and emits ``message.reaction`` for live painting."""
import contextlib
import json
from gateway.session_context import get_session_env
from tools import desktop_ui
from tools.registry import registry, tool_error
def _open_session_db():
"""Open the SessionDB for the profile owning this turn, or ``None``."""
try:
from hermes_state_registry import acquire
return acquire()
except Exception:
return None
def react_to_message_tool(emoji: str, message_row_id=None, messages_back=None) -> str:
"""Attach (or with an empty ``emoji`` retract) the agent's reaction."""
emoji = (emoji or "").strip()
session_key = get_session_env("HERMES_SESSION_KEY", "") or get_session_env("HERMES_SESSION_ID", "")
if not session_key:
return tool_error("No active session — reactions need a persisted conversation.")
db = _open_session_db()
if db is None:
return tool_error("Session storage is unavailable.")
try:
row_id, target_role = message_row_id, "user"
if row_id is None:
# Default: the latest user message; `messages_back` steps to earlier user turns
# (ids aren't visible to the model; "two messages ago" is how a person thinks).
back = max(0, int(messages_back or 0))
row_id = db.latest_message_row_id(session_key, role="user", offset=back)
if row_id is None:
return tool_error(f"No user message found {back} back." if back else "No user message to react to yet.")
else:
target_role = db.get_message_role(session_key, int(row_id)) or "user"
try:
reactions = db.set_message_reaction(session_key, int(row_id), emoji or None, author="agent")
except Exception as exc:
return tool_error(f"Failed to set the reaction: {exc}")
if reactions is None:
return tool_error(f"Message {row_id} is not part of this conversation.")
# Paint it live; a missing bridge (non-desktop) is not an error — the reaction is
# persisted. `role` lets the renderer match a live message without a durable row id.
with contextlib.suppress(Exception):
desktop_ui.emit("message.reaction", {"row_id": int(row_id), "reactions": reactions, "role": target_role})
return json.dumps({"success": True, "row_id": int(row_id), "reactions": reactions}, ensure_ascii=False)
finally:
with contextlib.suppress(Exception):
from hermes_state_registry import release_or_close
release_or_close(db)
def check_react_requirements() -> bool:
"""Opt-in flag (Settings → Appearance); ``desktop_ui`` already restricts to GUI sessions."""
return desktop_ui.user_enabled("message_reactions", default=False)
REACT_TO_MESSAGE_SCHEMA = {
"name": "react_to_message",
"description": (
"React to a message with a single emoji, the way you'd tapback in iMessage. "
"Reach for it when a reaction is what a person would do: something funny gets "
"a 😂, warmth gets a ❤️, a plan you're on board with gets a 👍 — then just "
"carry on with whatever the message actually needs. If a reaction says it "
"all, it can BE the reply (skip the redundant 'sounds good!' turn). Use it "
"like a person would: occasionally, when felt — not on every message, and "
"never as a status signal. NEVER narrate or explain a reaction ('I reacted "
"with...', 'Reacting now') — the emoji appearing on the bubble is the whole "
"point, and commentary kills it. Defaults to the user's most recent message. "
"One reaction per message: a different emoji replaces yours, an empty string "
"retracts it."
),
"parameters": {
"type": "object",
"properties": {
"emoji": {
"type": "string",
"description": (
"The emoji to react with (e.g. '❤️', '😂', '👍'). Pass an empty "
"string to remove your reaction."
),
},
"message_row_id": {
"type": "integer",
"description": (
"Optional. The specific message to react to. Omit to react to the "
"user's latest message, which is almost always what you want."
),
},
"messages_back": {
"type": "integer",
"description": (
"Optional. React to an EARLIER user message: 1 = the one before "
"the latest, 2 = two before, and so on. For when something lands "
"late — the joke you only got after answering."
),
},
},
"required": ["emoji"],
},
}
registry.register(
name="react_to_message", toolset="desktop_ui", schema=REACT_TO_MESSAGE_SCHEMA,
handler=lambda args, **kw: react_to_message_tool(
emoji=args.get("emoji", ""), message_row_id=args.get("message_row_id"),
messages_back=args.get("messages_back")),
check_fn=check_react_requirements, emoji="💛",
)